diff --git a/.gitignore b/.gitignore
index 283e179..4042721 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,4 +2,8 @@
__pycache__/
*.pyc
*.pyo
-.venv
\ No newline at end of file
+.venv
+
+# Frontend
+app/frontend/node_modules/
+app/frontend/dist/
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
index a401088..d69c71a 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,7 +1,17 @@
+# ── Stage 1: Build React frontend ─────────────────────────────────────────────
+FROM node:20-slim AS frontend-build
+WORKDIR /frontend
+COPY app/frontend/package.json app/frontend/package-lock.json ./
+RUN npm ci --legacy-peer-deps
+COPY app/frontend/ .
+RUN npm run build
+
+# ── Stage 2: Python backend ──────────────────────────────────────────────────
FROM python:3.11-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends gcc libc6-dev && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY ./app /app/app
+COPY --from=frontend-build /frontend/dist /app/app/frontend/dist
CMD ["sh", "-c", "uvicorn app.main:app --host 0.0.0.0 --port 8000 ${BASE_PATH:+--root-path $BASE_PATH}"]
diff --git a/README.md b/README.md
index 7c70378..593149e 100644
--- a/README.md
+++ b/README.md
@@ -15,7 +15,7 @@ Integrated toolpath optimization platform for CNC pen plotters. Converts vector
- Geometric line overlap detection and filtering
- WebSocket-driven progress monitoring
-**TECH STACK:** FastAPI | Python 3.11 | Vanilla JS | Canvas API
+**TECH STACK:** FastAPI | Python 3.11 | React + TypeScript + Vite | Canvas API
## BUILD PROCEDURE
@@ -27,6 +27,9 @@ docker compose up -d --build
Service available at `http://localhost:8080`
+- Legacy UI: `http://localhost:8080/`
+- React v2 UI: `http://localhost:8080/v2`
+
### SUBPATH HOSTING
For reverse-proxy deployments under dedicated routes (e.g., `domain.com/plotter-tool`):
@@ -59,20 +62,125 @@ location /plotter-tool {
### LOCAL DEVELOPMENT
```bash
+# Backend
pip install -r requirements.txt
uvicorn app.main:app --reload
+
+# Frontend (separate terminal)
+cd app/frontend
+npm install --legacy-peer-deps
+npm run dev # Dev server with hot-reload (proxied to backend)
+npm run build # Production build → app/frontend/dist/
+npm test # Run Vitest unit tests
```
Optional: `BASE_PATH=/plotter-tool uvicorn app.main:app --reload`
+## FRONTEND ARCHITECTURE
+
+The v2 frontend is a React + TypeScript SPA built with Vite, using a feature-sliced structure:
+
+```
+app/frontend/src/
+├── api/ # Typed API client for v2 endpoints
+├── store/ # Zustand state management
+├── features/
+│ ├── settings/ # Schema-driven settings panel (debounced, optimistic)
+│ ├── path-preview/ # Canvas-based path visualization with layer toggling
+│ └── upload/ # File upload, optimization trigger, download controls
+├── components/ # Shared UI components (Terminal, StatusBar)
+├── types.ts # Shared TypeScript types matching backend schemas
+└── __tests__/ # Unit tests (store reducers, event handling)
+```
+
+### Preview Layers
+
+The path preview engine renders multiple visualization layers that users can toggle:
+
+| Layer | Color | Description |
+|-------|-------|-------------|
+| `original` | Grey | Initial parsed paths |
+| `filtered` | Red | Paths removed by pen-width filter |
+| `greedy` | Blue | After nearest-neighbor sort |
+| `merged` | Green | After path merging |
+| `twoopt` | Yellow | After 2-opt refinement |
+| `final` | White | Final optimized paths + travel moves |
+
+## API REFERENCE
+
+### Legacy Endpoints (v1)
+
+| Method | Endpoint | Description |
+|--------|----------|-------------|
+| `POST` | `/upload` | Upload G-code file |
+| `POST` | `/upload-svg` | Upload SVG file |
+| `WS` | `/ws/{job_id}` | Optimization WebSocket |
+| `GET` | `/download/{job_id}` | Download optimized G-code |
+
+### v2 Endpoints
+
+| Method | Endpoint | Description |
+|--------|----------|-------------|
+| `POST` | `/api/v2/upload` | Upload G-code (typed response) |
+| `POST` | `/api/v2/upload-svg` | Upload SVG (typed response) |
+| `GET` | `/api/v2/job/{job_id}` | Get job status |
+| `GET` | `/api/v2/download/{job_id}` | Download optimized G-code |
+| `GET` | `/api/v2/settings/schema` | Get settings JSON Schema |
+| `WS` | `/api/v2/ws/{job_id}` | Optimization WebSocket (typed events) |
+
+### WebSocket Event Types
+
+Events sent during optimization over the v2 WebSocket:
+
+| Event | Phase | Key Fields |
+|-------|-------|------------|
+| `log` | Any | `msg` |
+| `filter_start` | Filter | `path_count`, `pen_width`, `visibility_threshold` |
+| `filter_result` | Filter | `removed_count`, `kept_count`, `removed_indices` |
+| `greedy_result` | Greedy | `paths`, `progress_history`, `original_dist`, `phase1_dist` |
+| `merge_result` | Merge | `paths`, `merge_count`, `post_merge_dist` |
+| `twoopt_start` | 2-OPT | `estimated_paths` |
+| `phase2_result` | 2-OPT | `paths`, `iterations`, `dist_history`, `final_dist` |
+| `complete` | Done | `job_id` |
+| `ping` | Any | Keep-alive during long operations |
+| `phase_progress` | Any | `phase`, `progress` (0–100) |
+
+### Settings Schema
+
+All settings are defined in `app/models.py` as a Pydantic `OptimizerSettings` model. The JSON Schema is available at `GET /api/v2/settings/schema`.
+
+| Setting | Type | Default | Description |
+|---------|------|---------|-------------|
+| `z_up` | float | 2.0 | Pen lift height (mm) |
+| `z_down` | float | 0.0 | Pen down height (mm) |
+| `feedrate` | float | 1000 | Draw speed (mm/min) |
+| `travel_speed` | float\|null | 3000 | Travel speed (mm/min) |
+| `z_speed` | float\|null | 500 | Z-axis speed (mm/min) |
+| `curve_tolerance` | float | 0.1 | SVG curve discretization (mm) |
+| `pen_width` | float | 0 | Pen width for filter (0=disabled) |
+| `visibility_threshold` | float | 50 | Min visibility % to keep line |
+| `offset_closed_paths` | bool | false | Offset closed paths inward |
+| `merge_threshold` | float | 0.5 | Max gap to merge paths (mm) |
+| `gcode_header` | string | "G28" | Startup G-code commands |
+| `gcode_footer` | string | "G0 Z5\nG0 X10 Y10\nM84" | Shutdown G-code commands |
+
## CONFIGURATION
-All operational parameters (feed rates, Z-axis control, curve tolerance, pen width filtering) are configurable via the web interface. Settings persist in browser local storage.
+All operational parameters are configurable via either the legacy or v2 web interface. Settings persist in browser local storage under the key `cyberplotter_settings`.
## DEPENDENCIES
This system incorporates [svg2gcode](https://github.com/sameer/svg2gcode) for SVG decomposition and G-code generation. Path optimization and overlap filtering are implemented internally.
+## EXTENSION GUIDELINES
+
+The v2 frontend is designed for future extension:
+
+- **New settings**: Add fields to `OptimizerSettings` in `app/models.py` and `OptimizerSettings` in `src/types.ts`. The settings panel renders them automatically.
+- **New preview layers**: Add a key to `PreviewLayers` in `src/types.ts` and a colour entry in `PathPreview.tsx`.
+- **New WS events**: Add the event type to `WSEvent` union in `src/types.ts` and handle it in `appStore.ts` `handleWSEvent`.
+- **Vectorization**: The settings panel and preview architecture support future vectorization features without refactor. Add vectorization-specific settings and preview layers when ready.
+
## DISCLAIMER
This application was shamelessly vibe-coded by an individual whose close-to-zero coding skills are a source of deep, profound shame. The code quality reflects this unfortunate reality. Proceed with appropriate caution.
diff --git a/app/frontend/index.html b/app/frontend/index.html
new file mode 100644
index 0000000..cf01b1d
--- /dev/null
+++ b/app/frontend/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ PLOTTERTOOL // SVG & G-CODE TOOLSET v2
+
+
+
+
+
+
diff --git a/app/frontend/package-lock.json b/app/frontend/package-lock.json
new file mode 100644
index 0000000..98fb0f9
--- /dev/null
+++ b/app/frontend/package-lock.json
@@ -0,0 +1,2626 @@
+{
+ "name": "plottertool-frontend",
+ "version": "2.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "plottertool-frontend",
+ "version": "2.0.0",
+ "dependencies": {
+ "react": "^19.2.4",
+ "react-dom": "^19.2.4",
+ "zustand": "^5.0.12"
+ },
+ "devDependencies": {
+ "@testing-library/jest-dom": "^6.6.3",
+ "@testing-library/react": "^16.3.0",
+ "@types/react": "^19.1.6",
+ "@types/react-dom": "^19.1.6",
+ "@vitejs/plugin-react": "^4.4.1",
+ "jsdom": "^26.1.0",
+ "typescript": "^5.8.3",
+ "vite": "^8.0.3",
+ "vitest": "^4.1.1"
+ }
+ },
+ "node_modules/@adobe/css-tools": {
+ "version": "4.4.4",
+ "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz",
+ "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@asamuzakjp/css-color": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
+ "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/css-calc": "^2.1.3",
+ "@csstools/css-color-parser": "^3.0.9",
+ "@csstools/css-parser-algorithms": "^3.0.4",
+ "@csstools/css-tokenizer": "^3.0.3",
+ "lru-cache": "^10.4.3"
+ }
+ },
+ "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
+ "version": "10.4.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
+ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
+ "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.28.5",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/compat-data": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz",
+ "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/core": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
+ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.0",
+ "@babel/generator": "^7.29.0",
+ "@babel/helper-compilation-targets": "^7.28.6",
+ "@babel/helper-module-transforms": "^7.28.6",
+ "@babel/helpers": "^7.28.6",
+ "@babel/parser": "^7.29.0",
+ "@babel/template": "^7.28.6",
+ "@babel/traverse": "^7.29.0",
+ "@babel/types": "^7.29.0",
+ "@jridgewell/remapping": "^2.3.5",
+ "convert-source-map": "^2.0.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.3",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "node_modules/@babel/generator": {
+ "version": "7.29.1",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
+ "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.0",
+ "@babel/types": "^7.29.0",
+ "@jridgewell/gen-mapping": "^0.3.12",
+ "@jridgewell/trace-mapping": "^0.3.28",
+ "jsesc": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
+ "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.28.6",
+ "@babel/helper-validator-option": "^7.27.1",
+ "browserslist": "^4.24.0",
+ "lru-cache": "^5.1.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-globals": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
+ "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
+ "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.28.6",
+ "@babel/types": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
+ "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.28.6",
+ "@babel/helper-validator-identifier": "^7.28.5",
+ "@babel/traverse": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-plugin-utils": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
+ "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
+ "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
+ "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
+ "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.29.2",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz",
+ "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.28.6",
+ "@babel/types": "^7.29.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.2",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz",
+ "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.0"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-self": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz",
+ "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-source": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz",
+ "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/runtime": {
+ "version": "7.29.2",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
+ "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/template": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
+ "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.28.6",
+ "@babel/parser": "^7.28.6",
+ "@babel/types": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
+ "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.0",
+ "@babel/generator": "^7.29.0",
+ "@babel/helper-globals": "^7.28.0",
+ "@babel/parser": "^7.29.0",
+ "@babel/template": "^7.28.6",
+ "@babel/types": "^7.29.0",
+ "debug": "^4.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
+ "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.27.1",
+ "@babel/helper-validator-identifier": "^7.28.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@csstools/color-helpers": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
+ "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@csstools/css-calc": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz",
+ "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^3.0.5",
+ "@csstools/css-tokenizer": "^3.0.4"
+ }
+ },
+ "node_modules/@csstools/css-color-parser": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz",
+ "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/color-helpers": "^5.1.0",
+ "@csstools/css-calc": "^2.1.4"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^3.0.5",
+ "@csstools/css-tokenizer": "^3.0.4"
+ }
+ },
+ "node_modules/@csstools/css-parser-algorithms": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
+ "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@csstools/css-tokenizer": "^3.0.4"
+ }
+ },
+ "node_modules/@csstools/css-tokenizer": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
+ "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@emnapi/core": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz",
+ "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.2.0",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz",
+ "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/wasi-threads": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz",
+ "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@napi-rs/wasm-runtime": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz",
+ "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "^1.7.1",
+ "@emnapi/runtime": "^1.7.1",
+ "@tybys/wasm-util": "^0.10.1"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ }
+ },
+ "node_modules/@oxc-project/types": {
+ "version": "0.122.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz",
+ "integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.0.0-rc.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz",
+ "integrity": "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-arm64": {
+ "version": "1.0.0-rc.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.12.tgz",
+ "integrity": "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-x64": {
+ "version": "1.0.0-rc.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz",
+ "integrity": "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-freebsd-x64": {
+ "version": "1.0.0-rc.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz",
+ "integrity": "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.0.0-rc.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz",
+ "integrity": "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.0.0-rc.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz",
+ "integrity": "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
+ "version": "1.0.0-rc.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz",
+ "integrity": "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+ "version": "1.0.0-rc.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz",
+ "integrity": "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
+ "version": "1.0.0-rc.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz",
+ "integrity": "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
+ "version": "1.0.0-rc.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz",
+ "integrity": "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-musl": {
+ "version": "1.0.0-rc.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz",
+ "integrity": "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-openharmony-arm64": {
+ "version": "1.0.0-rc.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz",
+ "integrity": "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-wasm32-wasi": {
+ "version": "1.0.0-rc.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz",
+ "integrity": "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==",
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@napi-rs/wasm-runtime": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.0.0-rc.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz",
+ "integrity": "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.0.0-rc.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.12.tgz",
+ "integrity": "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-beta.27",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
+ "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@testing-library/jest-dom": {
+ "version": "6.9.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
+ "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@adobe/css-tools": "^4.4.0",
+ "aria-query": "^5.0.0",
+ "css.escape": "^1.5.1",
+ "dom-accessibility-api": "^0.6.3",
+ "picocolors": "^1.1.1",
+ "redent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=14",
+ "npm": ">=6",
+ "yarn": ">=1"
+ }
+ },
+ "node_modules/@testing-library/react": {
+ "version": "16.3.2",
+ "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz",
+ "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.12.5"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": "^10.0.0",
+ "@types/react": "^18.0.0 || ^19.0.0",
+ "@types/react-dom": "^18.0.0 || ^19.0.0",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@tybys/wasm-util": {
+ "version": "0.10.1",
+ "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
+ "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@types/babel__core": {
+ "version": "7.20.5",
+ "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
+ "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.20.7",
+ "@babel/types": "^7.20.7",
+ "@types/babel__generator": "*",
+ "@types/babel__template": "*",
+ "@types/babel__traverse": "*"
+ }
+ },
+ "node_modules/@types/babel__generator": {
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
+ "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__template": {
+ "version": "7.4.4",
+ "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
+ "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.1.0",
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__traverse": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
+ "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.28.2"
+ }
+ },
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/react": {
+ "version": "19.2.14",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
+ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "19.2.3",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
+ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^19.2.0"
+ }
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
+ "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.28.0",
+ "@babel/plugin-transform-react-jsx-self": "^7.27.1",
+ "@babel/plugin-transform-react-jsx-source": "^7.27.1",
+ "@rolldown/pluginutils": "1.0.0-beta.27",
+ "@types/babel__core": "^7.20.5",
+ "react-refresh": "^0.17.0"
+ },
+ "engines": {
+ "node": "^14.18.0 || >=16.0.0"
+ },
+ "peerDependencies": {
+ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
+ }
+ },
+ "node_modules/@vitest/expect": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.1.tgz",
+ "integrity": "sha512-xAV0fqBTk44Rn6SjJReEQkHP3RrqbJo6JQ4zZ7/uVOiJZRarBtblzrOfFIZeYUrukp2YD6snZG6IBqhOoHTm+A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.1.0",
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "4.1.1",
+ "@vitest/utils": "4.1.1",
+ "chai": "^6.2.2",
+ "tinyrainbow": "^3.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.1.tgz",
+ "integrity": "sha512-h3BOylsfsCLPeceuCPAAJ+BvNwSENgJa4hXoXu4im0bs9Lyp4URc4JYK4pWLZ4pG/UQn7AT92K6IByi6rE6g3A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "4.1.1",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.21"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.1.tgz",
+ "integrity": "sha512-GM+TEQN5WhOygr1lp7skeVjdLPqqWMHsfzXrcHAqZJi/lIVh63H0kaRCY8MDhNWikx19zBUK8ceaLB7X5AH9NQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^3.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.1.tgz",
+ "integrity": "sha512-f7+FPy75vN91QGWsITueq0gedwUZy1fLtHOCMeQpjs8jTekAHeKP80zfDEnhrleviLHzVSDXIWuCIOFn3D3f8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "4.1.1",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.1.tgz",
+ "integrity": "sha512-kMVSgcegWV2FibXEx9p9WIKgje58lcTbXgnJixfcg15iK8nzCXhmalL0ZLtTWLW9PH1+1NEDShiFFedB3tEgWg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.1",
+ "@vitest/utils": "4.1.1",
+ "magic-string": "^0.30.21",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.1.tgz",
+ "integrity": "sha512-6Ti/KT5OVaiupdIZEuZN7l3CZcR0cxnxt70Z0//3CtwgObwA6jZhmVBA3yrXSVN3gmwjgd7oDNLlsXz526gpRA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.1.tgz",
+ "integrity": "sha512-cNxAlaB3sHoCdL6pj6yyUXv9Gry1NHNg0kFTXdvSIZXLHsqKH7chiWOkwJ5s5+d/oMwcoG9T0bKU38JZWKusrQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.1",
+ "convert-source-map": "^2.0.0",
+ "tinyrainbow": "^3.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/agent-base": {
+ "version": "7.1.4",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
+ "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/aria-query": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
+ "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.10.10",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.10.tgz",
+ "integrity": "sha512-sUoJ3IMxx4AyRqO4MLeHlnGDkyXRoUG0/AI9fjK+vS72ekpV0yWVY7O0BVjmBcRtkNcsAO2QDZ4tdKKGoI6YaQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.28.1",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
+ "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "baseline-browser-mapping": "^2.9.0",
+ "caniuse-lite": "^1.0.30001759",
+ "electron-to-chromium": "^1.5.263",
+ "node-releases": "^2.0.27",
+ "update-browserslist-db": "^1.2.0"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001781",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001781.tgz",
+ "integrity": "sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/chai": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
+ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/css.escape": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
+ "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/cssstyle": {
+ "version": "4.6.0",
+ "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
+ "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/css-color": "^3.2.0",
+ "rrweb-cssom": "^0.8.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/data-urls": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
+ "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-mimetype": "^4.0.0",
+ "whatwg-url": "^14.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/decimal.js": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
+ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/dom-accessibility-api": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
+ "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.325",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.325.tgz",
+ "integrity": "sha512-PwfIw7WQSt3xX7yOf5OE/unLzsK9CaN2f/FvV3WjPR1Knoc1T9vePRVV4W1EM301JzzysK51K7FNKcusCr0zYA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/entities": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
+ "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/es-module-lexer": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
+ "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/expect-type": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
+ "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/gensync": {
+ "version": "1.0.0-beta.2",
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/html-encoding-sniffer": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
+ "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-encoding": "^3.1.1"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/http-proxy-agent": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
+ "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.0",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/https-proxy-agent": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
+ "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.2",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/indent-string": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
+ "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-potential-custom-element-name": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
+ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/jsdom": {
+ "version": "26.1.0",
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz",
+ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cssstyle": "^4.2.1",
+ "data-urls": "^5.0.0",
+ "decimal.js": "^10.5.0",
+ "html-encoding-sniffer": "^4.0.0",
+ "http-proxy-agent": "^7.0.2",
+ "https-proxy-agent": "^7.0.6",
+ "is-potential-custom-element-name": "^1.0.1",
+ "nwsapi": "^2.2.16",
+ "parse5": "^7.2.1",
+ "rrweb-cssom": "^0.8.0",
+ "saxes": "^6.0.0",
+ "symbol-tree": "^3.2.4",
+ "tough-cookie": "^5.1.1",
+ "w3c-xmlserializer": "^5.0.0",
+ "webidl-conversions": "^7.0.0",
+ "whatwg-encoding": "^3.1.1",
+ "whatwg-mimetype": "^4.0.0",
+ "whatwg-url": "^14.1.1",
+ "ws": "^8.18.0",
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "canvas": "^3.0.0"
+ },
+ "peerDependenciesMeta": {
+ "canvas": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/jsesc": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/json5": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
+ "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.32.0",
+ "lightningcss-darwin-arm64": "1.32.0",
+ "lightningcss-darwin-x64": "1.32.0",
+ "lightningcss-freebsd-x64": "1.32.0",
+ "lightningcss-linux-arm-gnueabihf": "1.32.0",
+ "lightningcss-linux-arm64-gnu": "1.32.0",
+ "lightningcss-linux-arm64-musl": "1.32.0",
+ "lightningcss-linux-x64-gnu": "1.32.0",
+ "lightningcss-linux-x64-musl": "1.32.0",
+ "lightningcss-win32-arm64-msvc": "1.32.0",
+ "lightningcss-win32-x64-msvc": "1.32.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
+ "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
+ "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
+ "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
+ "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
+ "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
+ "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
+ "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
+ "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
+ "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
+ "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
+ "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/min-indent": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
+ "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.11",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.36",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz",
+ "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nwsapi": {
+ "version": "2.2.23",
+ "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz",
+ "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/obug": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
+ "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT"
+ },
+ "node_modules/parse5": {
+ "version": "7.3.0",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
+ "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "entities": "^6.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.8",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
+ "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/react": {
+ "version": "19.2.4",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
+ "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.2.4",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
+ "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.27.0"
+ },
+ "peerDependencies": {
+ "react": "^19.2.4"
+ }
+ },
+ "node_modules/react-refresh": {
+ "version": "0.17.0",
+ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
+ "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/redent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
+ "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "indent-string": "^4.0.0",
+ "strip-indent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/rolldown": {
+ "version": "1.0.0-rc.12",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz",
+ "integrity": "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@oxc-project/types": "=0.122.0",
+ "@rolldown/pluginutils": "1.0.0-rc.12"
+ },
+ "bin": {
+ "rolldown": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "optionalDependencies": {
+ "@rolldown/binding-android-arm64": "1.0.0-rc.12",
+ "@rolldown/binding-darwin-arm64": "1.0.0-rc.12",
+ "@rolldown/binding-darwin-x64": "1.0.0-rc.12",
+ "@rolldown/binding-freebsd-x64": "1.0.0-rc.12",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12",
+ "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12",
+ "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12",
+ "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12",
+ "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12",
+ "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12",
+ "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12",
+ "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12",
+ "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12",
+ "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12",
+ "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12"
+ }
+ },
+ "node_modules/rolldown/node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-rc.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz",
+ "integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/rrweb-cssom": {
+ "version": "0.8.0",
+ "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
+ "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/saxes": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
+ "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "xmlchars": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=v12.22.7"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.27.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
+ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/std-env": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz",
+ "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/strip-indent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
+ "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "min-indent": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/symbol-tree": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
+ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz",
+ "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.15",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
+ "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinyrainbow": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
+ "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tldts": {
+ "version": "6.1.86",
+ "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
+ "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tldts-core": "^6.1.86"
+ },
+ "bin": {
+ "tldts": "bin/cli.js"
+ }
+ },
+ "node_modules/tldts-core": {
+ "version": "6.1.86",
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz",
+ "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tough-cookie": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
+ "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "tldts": "^6.1.32"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
+ "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "dev": true,
+ "license": "0BSD",
+ "optional": true
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+ "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/vite": {
+ "version": "8.0.3",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz",
+ "integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lightningcss": "^1.32.0",
+ "picomatch": "^4.0.4",
+ "postcss": "^8.5.8",
+ "rolldown": "1.0.0-rc.12",
+ "tinyglobby": "^0.2.15"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "@vitejs/devtools": "^0.1.0",
+ "esbuild": "^0.27.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "@vitejs/devtools": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vitest": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.1.tgz",
+ "integrity": "sha512-yF+o4POL41rpAzj5KVILUxm1GCjKnELvaqmU9TLLUbMfDzuN0UpUR9uaDs+mCtjPe+uYPksXDRLQGGPvj1cTmA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/expect": "4.1.1",
+ "@vitest/mocker": "4.1.1",
+ "@vitest/pretty-format": "4.1.1",
+ "@vitest/runner": "4.1.1",
+ "@vitest/snapshot": "4.1.1",
+ "@vitest/spy": "4.1.1",
+ "@vitest/utils": "4.1.1",
+ "es-module-lexer": "^2.0.0",
+ "expect-type": "^1.3.0",
+ "magic-string": "^0.30.21",
+ "obug": "^2.1.1",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.3",
+ "std-env": "^4.0.0-rc.1",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^1.0.2",
+ "tinyglobby": "^0.2.15",
+ "tinyrainbow": "^3.0.3",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@opentelemetry/api": "^1.9.0",
+ "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "4.1.1",
+ "@vitest/browser-preview": "4.1.1",
+ "@vitest/browser-webdriverio": "4.1.1",
+ "@vitest/ui": "4.1.1",
+ "happy-dom": "*",
+ "jsdom": "*",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser-playwright": {
+ "optional": true
+ },
+ "@vitest/browser-preview": {
+ "optional": true
+ },
+ "@vitest/browser-webdriverio": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ },
+ "vite": {
+ "optional": false
+ }
+ }
+ },
+ "node_modules/w3c-xmlserializer": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
+ "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/webidl-conversions": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
+ "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/whatwg-encoding": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
+ "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
+ "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "iconv-lite": "0.6.3"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/whatwg-mimetype": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
+ "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/whatwg-url": {
+ "version": "14.2.0",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
+ "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tr46": "^5.1.0",
+ "webidl-conversions": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ws": {
+ "version": "8.20.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
+ "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/xml-name-validator": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
+ "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/xmlchars": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
+ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/zustand": {
+ "version": "5.0.12",
+ "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz",
+ "integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ },
+ "peerDependencies": {
+ "@types/react": ">=18.0.0",
+ "immer": ">=9.0.6",
+ "react": ">=18.0.0",
+ "use-sync-external-store": ">=1.2.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "immer": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "use-sync-external-store": {
+ "optional": true
+ }
+ }
+ }
+ }
+}
diff --git a/app/frontend/package.json b/app/frontend/package.json
new file mode 100644
index 0000000..2b704c1
--- /dev/null
+++ b/app/frontend/package.json
@@ -0,0 +1,30 @@
+{
+ "name": "plottertool-frontend",
+ "private": true,
+ "version": "2.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc -b && vite build",
+ "preview": "vite preview",
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "lint": "tsc --noEmit"
+ },
+ "dependencies": {
+ "react": "^19.2.4",
+ "react-dom": "^19.2.4",
+ "zustand": "^5.0.12"
+ },
+ "devDependencies": {
+ "@testing-library/jest-dom": "^6.6.3",
+ "@testing-library/react": "^16.3.0",
+ "@types/react": "^19.1.6",
+ "@types/react-dom": "^19.1.6",
+ "@vitejs/plugin-react": "^4.4.1",
+ "jsdom": "^26.1.0",
+ "typescript": "^5.8.3",
+ "vite": "^8.0.3",
+ "vitest": "^4.1.1"
+ }
+}
diff --git a/app/frontend/src/App.tsx b/app/frontend/src/App.tsx
new file mode 100644
index 0000000..c41005b
--- /dev/null
+++ b/app/frontend/src/App.tsx
@@ -0,0 +1,35 @@
+import { useState } from 'react';
+import { Terminal, StatusBar } from './components';
+import { SettingsPanel } from './features/settings';
+import { PathPreview } from './features/path-preview';
+import { UploadControls } from './features/upload';
+
+export default function App() {
+ const [showSettings, setShowSettings] = useState(false);
+
+ return (
+ <>
+
+
+
+ SYS.OP // MFCORP PLOTTERTOOL_V2.5
+ setShowSettings(true)} />
+
+
+ {showSettings &&
setShowSettings(false)} />}
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/app/frontend/src/__tests__/appStore.test.ts b/app/frontend/src/__tests__/appStore.test.ts
new file mode 100644
index 0000000..cc83507
--- /dev/null
+++ b/app/frontend/src/__tests__/appStore.test.ts
@@ -0,0 +1,220 @@
+/**
+ * Unit tests for the Zustand app store and WebSocket event reducers.
+ */
+
+import { describe, it, expect, beforeEach } from 'vitest';
+import { useAppStore } from '../store/appStore';
+import { DEFAULT_SETTINGS } from '../types';
+import type { WSEvent } from '../types';
+
+function resetStore() {
+ useAppStore.setState({
+ settings: { ...DEFAULT_SETTINGS },
+ jobId: null,
+ phase: 'idle',
+ logs: [],
+ progress: 0,
+ stats: {
+ originalDist: 0,
+ phase1Dist: 0,
+ finalDist: 0,
+ gcodeDist: 0,
+ iterations: 0,
+ distHistory: [],
+ filterRemoved: 0,
+ filterKept: 0,
+ removedIndices: [],
+ mergeCount: 0,
+ },
+ layers: {
+ original: [],
+ filtered: null,
+ greedy: null,
+ merged: null,
+ twoopt: null,
+ final: null,
+ },
+ });
+}
+
+describe('AppStore', () => {
+ beforeEach(() => {
+ resetStore();
+ });
+
+ describe('settings', () => {
+ it('has correct default settings', () => {
+ const state = useAppStore.getState();
+ expect(state.settings.z_up).toBe(2.0);
+ expect(state.settings.feedrate).toBe(1000);
+ expect(state.settings.pen_width).toBe(0);
+ });
+
+ it('updates settings partially', () => {
+ useAppStore.getState().updateSettings({ z_up: 5.0 });
+ expect(useAppStore.getState().settings.z_up).toBe(5.0);
+ expect(useAppStore.getState().settings.feedrate).toBe(1000);
+ });
+
+ it('resets settings to defaults', () => {
+ useAppStore.getState().updateSettings({ z_up: 99 });
+ useAppStore.getState().resetSettings();
+ expect(useAppStore.getState().settings.z_up).toBe(2.0);
+ });
+ });
+
+ describe('job state', () => {
+ it('starts in idle phase', () => {
+ expect(useAppStore.getState().phase).toBe('idle');
+ });
+
+ it('sets job id', () => {
+ useAppStore.getState().setJobId('test-123');
+ expect(useAppStore.getState().jobId).toBe('test-123');
+ });
+
+ it('adds logs', () => {
+ useAppStore.getState().addLog('Hello');
+ useAppStore.getState().addLog('World');
+ expect(useAppStore.getState().logs).toEqual(['Hello', 'World']);
+ });
+
+ it('sets original paths and transitions to uploaded', () => {
+ const paths = [[{ x: 0, y: 0 }, { x: 10, y: 10 }]];
+ useAppStore.getState().setOriginalPaths(paths);
+ expect(useAppStore.getState().phase).toBe('uploaded');
+ expect(useAppStore.getState().layers.original).toEqual(paths);
+ });
+
+ it('reset clears state', () => {
+ useAppStore.getState().setJobId('id');
+ useAppStore.getState().addLog('msg');
+ useAppStore.getState().reset();
+ expect(useAppStore.getState().jobId).toBeNull();
+ expect(useAppStore.getState().logs).toEqual([]);
+ expect(useAppStore.getState().phase).toBe('idle');
+ });
+ });
+
+ describe('handleWSEvent', () => {
+ it('handles log events', () => {
+ const event: WSEvent = { type: 'log', msg: 'Test message' };
+ useAppStore.getState().handleWSEvent(event);
+ expect(useAppStore.getState().logs).toContain('Test message');
+ });
+
+ it('handles filter_start', () => {
+ const event: WSEvent = { type: 'filter_start', path_count: 10, pen_width: 0.5, visibility_threshold: 50 };
+ useAppStore.getState().handleWSEvent(event);
+ expect(useAppStore.getState().phase).toBe('filtering');
+ });
+
+ it('handles filter_result', () => {
+ const event: WSEvent = {
+ type: 'filter_result',
+ original_count: 10,
+ removed_count: 3,
+ kept_count: 7,
+ removed_indices: [1, 5, 8],
+ pen_width: 0.5,
+ visibility_threshold: 50,
+ };
+ useAppStore.getState().handleWSEvent(event);
+ expect(useAppStore.getState().stats.filterRemoved).toBe(3);
+ expect(useAppStore.getState().stats.filterKept).toBe(7);
+ expect(useAppStore.getState().stats.removedIndices).toEqual([1, 5, 8]);
+ });
+
+ it('handles greedy_result', () => {
+ const paths = [[{ x: 0, y: 0 }, { x: 5, y: 5 }]];
+ const event: WSEvent = {
+ type: 'greedy_result',
+ paths,
+ progress_history: [100, 80, 60],
+ original_dist: 200,
+ phase1_dist: 100,
+ path_count: 1,
+ };
+ useAppStore.getState().handleWSEvent(event);
+ expect(useAppStore.getState().phase).toBe('greedy');
+ expect(useAppStore.getState().layers.greedy).toEqual(paths);
+ expect(useAppStore.getState().stats.originalDist).toBe(200);
+ expect(useAppStore.getState().stats.phase1Dist).toBe(100);
+ });
+
+ it('handles twoopt_start', () => {
+ const event: WSEvent = { type: 'twoopt_start', estimated_paths: 50 };
+ useAppStore.getState().handleWSEvent(event);
+ expect(useAppStore.getState().phase).toBe('twoopt');
+ });
+
+ it('handles phase2_result', () => {
+ const paths = [[{ x: 0, y: 0 }, { x: 1, y: 1 }]];
+ const event: WSEvent = {
+ type: 'phase2_result',
+ iterations: 500,
+ dist_history: [100, 90, 80],
+ paths,
+ original_dist: 100,
+ gcode_dist: 200,
+ phase1_dist: 100,
+ final_dist: 80,
+ };
+ useAppStore.getState().handleWSEvent(event);
+ expect(useAppStore.getState().layers.final).toEqual(paths);
+ expect(useAppStore.getState().stats.iterations).toBe(500);
+ expect(useAppStore.getState().stats.finalDist).toBe(80);
+ });
+
+ it('handles complete', () => {
+ const event: WSEvent = { type: 'complete', job_id: 'abc' };
+ useAppStore.getState().handleWSEvent(event);
+ expect(useAppStore.getState().phase).toBe('complete');
+ });
+
+ it('handles error', () => {
+ const event: WSEvent = { type: 'error', msg: 'Something failed' };
+ useAppStore.getState().handleWSEvent(event);
+ expect(useAppStore.getState().phase).toBe('error');
+ expect(useAppStore.getState().logs).toContain('ERROR: Something failed');
+ });
+
+ it('handles ping (no-op)', () => {
+ const prevState = useAppStore.getState().phase;
+ useAppStore.getState().handleWSEvent({ type: 'ping' });
+ expect(useAppStore.getState().phase).toBe(prevState);
+ });
+
+ it('handles phase_progress', () => {
+ const event: WSEvent = { type: 'phase_progress', phase: 'greedy', progress: 42 };
+ useAppStore.getState().handleWSEvent(event);
+ expect(useAppStore.getState().progress).toBe(42);
+ });
+
+ it('handles merge_result', () => {
+ const paths = [[{ x: 0, y: 0 }, { x: 1, y: 1 }]];
+ const event: WSEvent = {
+ type: 'merge_result',
+ paths,
+ original_count: 10,
+ merged_count: 8,
+ merge_count: 2,
+ post_merge_dist: 50,
+ };
+ useAppStore.getState().handleWSEvent(event);
+ expect(useAppStore.getState().phase).toBe('merging');
+ expect(useAppStore.getState().layers.merged).toEqual(paths);
+ expect(useAppStore.getState().stats.mergeCount).toBe(2);
+ });
+ });
+
+ describe('layer toggling', () => {
+ it('toggles layer visibility', () => {
+ expect(useAppStore.getState().visibleLayers.original).toBe(true);
+ useAppStore.getState().toggleLayer('original');
+ expect(useAppStore.getState().visibleLayers.original).toBe(false);
+ useAppStore.getState().toggleLayer('original');
+ expect(useAppStore.getState().visibleLayers.original).toBe(true);
+ });
+ });
+});
diff --git a/app/frontend/src/__tests__/setup.ts b/app/frontend/src/__tests__/setup.ts
new file mode 100644
index 0000000..7b0828b
--- /dev/null
+++ b/app/frontend/src/__tests__/setup.ts
@@ -0,0 +1 @@
+import '@testing-library/jest-dom';
diff --git a/app/frontend/src/api/client.ts b/app/frontend/src/api/client.ts
new file mode 100644
index 0000000..0334263
--- /dev/null
+++ b/app/frontend/src/api/client.ts
@@ -0,0 +1,49 @@
+/** API client for v2 endpoints. */
+
+import type { UploadResponse, OptimizerSettings, JobStatusResponse } from '../types';
+
+function apiBase(): string {
+ // In dev the Vite proxy forwards /api/v2 to the backend.
+ // In production the FastAPI serves both SPA and API.
+ return '/api/v2';
+}
+
+export async function uploadFile(
+ file: File,
+ settings: OptimizerSettings,
+): Promise {
+ const form = new FormData();
+ form.append('file', file);
+ form.append('settings', JSON.stringify(settings));
+
+ const endpoint = file.name.endsWith('.svg') ? '/upload-svg' : '/upload';
+ const res = await fetch(`${apiBase()}${endpoint}`, { method: 'POST', body: form });
+ if (!res.ok) {
+ const body = await res.json().catch(() => null);
+ throw new Error(body?.detail ?? `Upload failed (${res.status})`);
+ }
+ return res.json();
+}
+
+export async function getJob(jobId: string): Promise {
+ const res = await fetch(`${apiBase()}/job/${jobId}`);
+ if (!res.ok) throw new Error(`Job not found (${res.status})`);
+ return res.json();
+}
+
+export function downloadUrl(jobId: string): string {
+ return `${apiBase()}/download/${jobId}`;
+}
+
+export async function getSettingsSchema(): Promise> {
+ const res = await fetch(`${apiBase()}/settings/schema`);
+ if (!res.ok) throw new Error('Failed to fetch settings schema');
+ return res.json();
+}
+
+/** Create a WebSocket connection to the v2 optimization endpoint. */
+export function createOptimizationWS(jobId: string): WebSocket {
+ const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
+ const base = `${proto}://${window.location.host}`;
+ return new WebSocket(`${base}${apiBase()}/ws/${jobId}`);
+}
diff --git a/app/frontend/src/api/index.ts b/app/frontend/src/api/index.ts
new file mode 100644
index 0000000..78a6a51
--- /dev/null
+++ b/app/frontend/src/api/index.ts
@@ -0,0 +1 @@
+export { uploadFile, getJob, downloadUrl, getSettingsSchema, createOptimizationWS } from './client';
diff --git a/app/frontend/src/components/StatusBar.tsx b/app/frontend/src/components/StatusBar.tsx
new file mode 100644
index 0000000..332c09e
--- /dev/null
+++ b/app/frontend/src/components/StatusBar.tsx
@@ -0,0 +1,43 @@
+/** Status bar with phase indicator. */
+
+import { useAppStore } from '../store';
+
+export default function StatusBar({
+ onSettingsClick,
+}: {
+ onSettingsClick: () => void;
+}) {
+ const phase = useAppStore((s) => s.phase);
+
+ const statusMap: Record = {
+ idle: 'STATUS: IDLE',
+ uploading: 'STATUS: UPLOADING...',
+ uploaded: 'STATUS: FILE LOADED',
+ filtering: 'STATUS: LINE FILTER ACTIVE',
+ greedy: 'STATUS: GREEDY SORT',
+ merging: 'STATUS: MERGING PATHS',
+ twoopt: 'STATUS: 2-OPT REFINEMENT',
+ complete: 'STATUS: OPTIMIZATION COMPLETE',
+ error: 'STATUS: ERROR',
+ };
+
+ const dotClass =
+ phase === 'complete'
+ ? 'status-indicator complete'
+ : ['filtering', 'greedy', 'merging', 'twoopt', 'uploading'].includes(phase)
+ ? 'status-indicator active'
+ : phase === 'error'
+ ? 'status-indicator error'
+ : 'status-indicator';
+
+ return (
+
+
+
{statusMap[phase] ?? 'STATUS: UNKNOWN'}
+
_
+
+
+ );
+}
diff --git a/app/frontend/src/components/Terminal.tsx b/app/frontend/src/components/Terminal.tsx
new file mode 100644
index 0000000..3316a78
--- /dev/null
+++ b/app/frontend/src/components/Terminal.tsx
@@ -0,0 +1,23 @@
+/** Terminal log output component. */
+
+import { useEffect, useRef } from 'react';
+import { useAppStore } from '../store';
+
+export default function Terminal() {
+ const logs = useAppStore((s) => s.logs);
+ const bottomRef = useRef(null);
+
+ useEffect(() => {
+ bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
+ }, [logs]);
+
+ return (
+
+ {logs.length === 0 &&
> SYSTEM READY. AWAITING INPUT...
}
+ {logs.map((msg, i) => (
+
> {msg}
+ ))}
+
+
+ );
+}
diff --git a/app/frontend/src/components/index.ts b/app/frontend/src/components/index.ts
new file mode 100644
index 0000000..11e7f64
--- /dev/null
+++ b/app/frontend/src/components/index.ts
@@ -0,0 +1,2 @@
+export { default as Terminal } from './Terminal';
+export { default as StatusBar } from './StatusBar';
diff --git a/app/frontend/src/features/path-preview/PathPreview.tsx b/app/frontend/src/features/path-preview/PathPreview.tsx
new file mode 100644
index 0000000..ef2bb37
--- /dev/null
+++ b/app/frontend/src/features/path-preview/PathPreview.tsx
@@ -0,0 +1,255 @@
+/**
+ * High-performance Canvas-based path preview with layer toggling.
+ *
+ * Renders paths on a 2D canvas with:
+ * - Viewport transform (auto-fit + centering)
+ * - Per-layer colours and toggling
+ * - High-DPI support (devicePixelRatio)
+ * - Throttled redraws
+ */
+
+import { useEffect, useRef, useCallback } from 'react';
+import { useAppStore } from '../../store';
+import type { PathData, PreviewLayers } from '../../types';
+
+const LAYER_COLORS: Record = {
+ original: 'rgba(100,100,100,0.35)',
+ filtered: 'rgba(255,80,80,0.5)',
+ greedy: 'rgba(80,180,255,0.6)',
+ merged: 'rgba(80,255,180,0.6)',
+ twoopt: 'rgba(255,200,80,0.7)',
+ final: 'rgba(255,255,255,0.9)',
+};
+
+function getBounds(paths: PathData[]) {
+ let minX = Infinity,
+ minY = Infinity,
+ maxX = -Infinity,
+ maxY = -Infinity;
+ for (const path of paths) {
+ for (const p of path) {
+ if (p.x < minX) minX = p.x;
+ if (p.y < minY) minY = p.y;
+ if (p.x > maxX) maxX = p.x;
+ if (p.y > maxY) maxY = p.y;
+ }
+ }
+ return { minX, minY, maxX, maxY };
+}
+
+export default function PathPreview() {
+ const canvasRef = useRef(null);
+ const containerRef = useRef(null);
+ const frameRef = useRef(0);
+
+ const layers = useAppStore((s) => s.layers);
+ const visibleLayers = useAppStore((s) => s.visibleLayers);
+ const toggleLayer = useAppStore((s) => s.toggleLayer);
+ const phase = useAppStore((s) => s.phase);
+
+ const draw = useCallback(() => {
+ const canvas = canvasRef.current;
+ const container = containerRef.current;
+ if (!canvas || !container) return;
+
+ const dpr = window.devicePixelRatio || 1;
+ const rect = container.getBoundingClientRect();
+ canvas.width = rect.width * dpr;
+ canvas.height = rect.height * dpr;
+ canvas.style.width = `${rect.width}px`;
+ canvas.style.height = `${rect.height}px`;
+
+ const ctx = canvas.getContext('2d');
+ if (!ctx) return;
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+
+ // Clear
+ ctx.fillStyle = '#0a0e14';
+ ctx.fillRect(0, 0, rect.width, rect.height);
+
+ // Collect all visible paths to compute bounds
+ const allPaths: PathData[] = [];
+ for (const key of Object.keys(layers) as (keyof PreviewLayers)[]) {
+ const data = layers[key];
+ if (data && visibleLayers[key]) {
+ allPaths.push(...data);
+ }
+ }
+ // Always include original for bounds even if hidden
+ if (layers.original.length) allPaths.push(...layers.original);
+ if (!allPaths.length) return;
+
+ const bounds = getBounds(allPaths);
+ const dataW = bounds.maxX - bounds.minX || 1;
+ const dataH = bounds.maxY - bounds.minY || 1;
+ const pad = 20;
+ const scaleX = (rect.width - pad * 2) / dataW;
+ const scaleY = (rect.height - pad * 2) / dataH;
+ const scale = Math.min(scaleX, scaleY);
+ const offX = (rect.width - dataW * scale) / 2;
+ const offY = (rect.height - dataH * scale) / 2;
+
+ const tx = (x: number) => (x - bounds.minX) * scale + offX;
+ const ty = (y: number) => (y - bounds.minY) * scale + offY;
+
+ // Draw grid
+ ctx.strokeStyle = 'rgba(255,255,255,0.04)';
+ ctx.lineWidth = 0.5;
+ const gridStep = 10;
+ for (let gx = Math.floor(bounds.minX / gridStep) * gridStep; gx <= bounds.maxX; gx += gridStep) {
+ ctx.beginPath();
+ ctx.moveTo(tx(gx), ty(bounds.minY));
+ ctx.lineTo(tx(gx), ty(bounds.maxY));
+ ctx.stroke();
+ }
+ for (let gy = Math.floor(bounds.minY / gridStep) * gridStep; gy <= bounds.maxY; gy += gridStep) {
+ ctx.beginPath();
+ ctx.moveTo(tx(bounds.minX), ty(gy));
+ ctx.lineTo(tx(bounds.maxX), ty(gy));
+ ctx.stroke();
+ }
+
+ // Draw layers in order
+ const drawOrder: (keyof PreviewLayers)[] = [
+ 'original',
+ 'filtered',
+ 'greedy',
+ 'merged',
+ 'twoopt',
+ 'final',
+ ];
+
+ for (const layerKey of drawOrder) {
+ const data = layers[layerKey];
+ if (!data || !visibleLayers[layerKey]) continue;
+
+ ctx.strokeStyle = LAYER_COLORS[layerKey];
+ ctx.lineWidth = layerKey === 'final' ? 1.5 : 1;
+ ctx.lineJoin = 'round';
+ ctx.lineCap = 'round';
+
+ for (const path of data) {
+ if (path.length < 2) continue;
+ ctx.beginPath();
+ const first = path[0];
+ if (first) {
+ ctx.moveTo(tx(first.x), ty(first.y));
+ }
+ for (let i = 1; i < path.length; i++) {
+ const pt = path[i];
+ if (pt) {
+ ctx.lineTo(tx(pt.x), ty(pt.y));
+ }
+ }
+ ctx.stroke();
+ }
+ }
+
+ // Draw travel moves for final layer
+ if (visibleLayers.final && layers.final && layers.final.length > 1) {
+ ctx.strokeStyle = 'rgba(255,160,50,0.25)';
+ ctx.lineWidth = 0.5;
+ ctx.setLineDash([4, 4]);
+ for (let i = 1; i < layers.final.length; i++) {
+ const prevPath = layers.final[i - 1];
+ const currPath = layers.final[i];
+ if (prevPath && currPath && prevPath.length > 0 && currPath.length > 0) {
+ const prevEnd = prevPath[prevPath.length - 1];
+ const currStart = currPath[0];
+ if (prevEnd && currStart) {
+ ctx.beginPath();
+ ctx.moveTo(tx(prevEnd.x), ty(prevEnd.y));
+ ctx.lineTo(tx(currStart.x), ty(currStart.y));
+ ctx.stroke();
+ }
+ }
+ }
+ ctx.setLineDash([]);
+ }
+ }, [layers, visibleLayers]);
+
+ // Throttled redraw via requestAnimationFrame
+ useEffect(() => {
+ cancelAnimationFrame(frameRef.current);
+ frameRef.current = requestAnimationFrame(draw);
+ return () => cancelAnimationFrame(frameRef.current);
+ }, [draw]);
+
+ // Resize observer
+ useEffect(() => {
+ const container = containerRef.current;
+ if (!container) return;
+ const observer = new ResizeObserver(() => {
+ cancelAnimationFrame(frameRef.current);
+ frameRef.current = requestAnimationFrame(draw);
+ });
+ observer.observe(container);
+ return () => observer.disconnect();
+ }, [draw]);
+
+ const availableLayers = (Object.keys(layers) as (keyof PreviewLayers)[]).filter(
+ (k) => layers[k] !== null && (Array.isArray(layers[k]) ? layers[k].length > 0 : false),
+ );
+
+ return (
+
+
TOOLPATH VISUALIZATION
+
+
+
+ {availableLayers.length > 1 && (
+
+ {availableLayers.map((key) => (
+
+ ))}
+
+ )}
+ {phase !== 'idle' && (
+
+
+
+ )}
+
+ );
+}
+
+function StatsOverlay() {
+ const phase = useAppStore((s) => s.phase);
+ const stats = useAppStore((s) => s.stats);
+ const layers = useAppStore((s) => s.layers);
+
+ const pathCount = layers.final?.length ?? layers.original.length;
+ const timeSaved =
+ stats.gcodeDist > 0
+ ? (((stats.gcodeDist - stats.finalDist) / stats.gcodeDist) * 100).toFixed(1)
+ : '---';
+
+ return (
+ <>
+
+ PHASE: {phase.toUpperCase()}
+
+ PATHS: {pathCount}
+ {stats.gcodeDist > 0 && (
+ <>
+
+ ORIGINAL: {stats.gcodeDist.toFixed(1)}mm
+ CURRENT: {stats.finalDist.toFixed(1)}mm
+
+ SAVED: {timeSaved}%
+
+ {stats.iterations > 0 && (
+ 2-OPT ITERS: {stats.iterations}
+ )}
+ >
+ )}
+ >
+ );
+}
diff --git a/app/frontend/src/features/path-preview/index.ts b/app/frontend/src/features/path-preview/index.ts
new file mode 100644
index 0000000..c7e7917
--- /dev/null
+++ b/app/frontend/src/features/path-preview/index.ts
@@ -0,0 +1 @@
+export { default as PathPreview } from './PathPreview';
diff --git a/app/frontend/src/features/settings/SettingsPanel.tsx b/app/frontend/src/features/settings/SettingsPanel.tsx
new file mode 100644
index 0000000..d7c9b23
--- /dev/null
+++ b/app/frontend/src/features/settings/SettingsPanel.tsx
@@ -0,0 +1,234 @@
+/** Settings panel – schema-driven, debounced inputs, optimistic UI. */
+
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { useAppStore } from '../../store';
+import type { OptimizerSettings } from '../../types';
+
+function debounce void>(fn: T, ms: number): T {
+ let timer: ReturnType;
+ return ((...args: Parameters) => {
+ clearTimeout(timer);
+ timer = setTimeout(() => fn(...args), ms);
+ }) as unknown as T;
+}
+
+interface SettingsRowProps {
+ label: string;
+ title?: string;
+ children: React.ReactNode;
+}
+
+function SettingsRow({ label, title, children }: SettingsRowProps) {
+ return (
+
+
+ {children}
+
+ );
+}
+
+export default function SettingsPanel({ onClose }: { onClose: () => void }) {
+ const settings = useAppStore((s) => s.settings);
+ const updateSettings = useAppStore((s) => s.updateSettings);
+ const resetSettings = useAppStore((s) => s.resetSettings);
+
+ // Local copy for optimistic updates
+ const [local, setLocal] = useState({ ...settings });
+ const commitRef = useRef(
+ debounce((partial: Partial) => {
+ updateSettings(partial);
+ }, 300),
+ );
+
+ // Sync if store changes externally
+ useEffect(() => {
+ setLocal({ ...settings });
+ }, [settings]);
+
+ const change = useCallback(
+ (key: K, value: OptimizerSettings[K]) => {
+ setLocal((prev) => ({ ...prev, [key]: value }));
+ commitRef.current({ [key]: value });
+ },
+ [],
+ );
+
+ const handleReset = () => {
+ resetSettings();
+ setLocal({ ...settings });
+ onClose();
+ };
+
+ return (
+
+
e.stopPropagation()}>
+
+ PLOTTERTOOL SETTINGS
+
+
+
+
+
+
+
+
+
PATH MERGING
+
+ change('merge_threshold', parseFloat(e.target.value) || 0)}
+ />
+
+
+
+
+
+
+
SVG
+
+ change('curve_tolerance', parseFloat(e.target.value) || 0.1)}
+ />
+
+
+
+
+
G-CODE
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/frontend/src/features/settings/index.ts b/app/frontend/src/features/settings/index.ts
new file mode 100644
index 0000000..975f4d8
--- /dev/null
+++ b/app/frontend/src/features/settings/index.ts
@@ -0,0 +1 @@
+export { default as SettingsPanel } from './SettingsPanel';
diff --git a/app/frontend/src/features/upload/UploadControls.tsx b/app/frontend/src/features/upload/UploadControls.tsx
new file mode 100644
index 0000000..5fc9f0d
--- /dev/null
+++ b/app/frontend/src/features/upload/UploadControls.tsx
@@ -0,0 +1,97 @@
+/** Upload, optimize, and download controls. */
+
+import { useCallback, useRef } from 'react';
+import { useAppStore } from '../../store';
+import { uploadFile, createOptimizationWS, downloadUrl } from '../../api';
+import type { WSEvent } from '../../types';
+
+export default function UploadControls() {
+ const fileRef = useRef(null);
+ const wsRef = useRef(null);
+
+ const settings = useAppStore((s) => s.settings);
+ const phase = useAppStore((s) => s.phase);
+ const jobId = useAppStore((s) => s.jobId);
+ const setJobId = useAppStore((s) => s.setJobId);
+ const setPhase = useAppStore((s) => s.setPhase);
+ const addLog = useAppStore((s) => s.addLog);
+ const setOriginalPaths = useAppStore((s) => s.setOriginalPaths);
+ const handleWSEvent = useAppStore((s) => s.handleWSEvent);
+ const reset = useAppStore((s) => s.reset);
+
+ const handleFileSelect = useCallback(async () => {
+ const file = fileRef.current?.files?.[0];
+ if (!file) return;
+
+ reset();
+ setPhase('uploading');
+ addLog(`Uploading ${file.name}...`);
+
+ try {
+ const resp = await uploadFile(file, settings);
+ setJobId(resp.job_id);
+ setOriginalPaths(resp.paths);
+ addLog(`Parsed ${resp.path_count} paths from ${resp.source.toUpperCase()}`);
+ } catch (err) {
+ addLog(`Upload failed: ${err instanceof Error ? err.message : String(err)}`);
+ setPhase('error');
+ }
+ }, [settings, reset, setPhase, addLog, setJobId, setOriginalPaths]);
+
+ const handleOptimize = useCallback(() => {
+ if (!jobId) return;
+ addLog('Starting optimization...');
+
+ const ws = createOptimizationWS(jobId);
+ wsRef.current = ws;
+
+ ws.onmessage = (e) => {
+ try {
+ const event: WSEvent = JSON.parse(e.data);
+ handleWSEvent(event);
+ } catch {
+ addLog(`Unknown WS message: ${e.data}`);
+ }
+ };
+
+ ws.onerror = () => {
+ addLog('WebSocket error');
+ setPhase('error');
+ };
+
+ ws.onclose = () => {
+ wsRef.current = null;
+ };
+ }, [jobId, addLog, handleWSEvent, setPhase]);
+
+ const canOptimize = phase === 'uploaded';
+ const canDownload = phase === 'complete' && jobId;
+ const isProcessing = ['filtering', 'greedy', 'merging', 'twoopt'].includes(phase);
+
+ return (
+
+
+
+
+ {canDownload && (
+
+ DOWNLOAD RESULT
+
+ )}
+
+ );
+}
diff --git a/app/frontend/src/features/upload/index.ts b/app/frontend/src/features/upload/index.ts
new file mode 100644
index 0000000..7f67a55
--- /dev/null
+++ b/app/frontend/src/features/upload/index.ts
@@ -0,0 +1 @@
+export { default as UploadControls } from './UploadControls';
diff --git a/app/frontend/src/index.css b/app/frontend/src/index.css
new file mode 100644
index 0000000..db63158
--- /dev/null
+++ b/app/frontend/src/index.css
@@ -0,0 +1,358 @@
+/* ─── PlotterTool React Frontend – Cyberpunk Theme ────────────────────────── */
+
+@import url('https://fonts.googleapis.com/css2?family=Share+Tech+Mono&display=swap');
+
+:root {
+ --bg: #0a0e14;
+ --panel-bg: #0d1117;
+ --border: #1a2332;
+ --text: #c9d1d9;
+ --text-dim: #6e7681;
+ --accent: #ff9500;
+ --accent-dim: rgba(255,149,0,0.3);
+ --highlight: #ff9500;
+ --btn-bg: #161b22;
+ --btn-hover: #21262d;
+ --input-bg: #0d1117;
+ --success: #3fb950;
+ --error: #f85149;
+ --font-mono: 'Share Tech Mono', monospace;
+}
+
+* { box-sizing: border-box; margin: 0; padding: 0; }
+
+body {
+ font-family: var(--font-mono);
+ background: var(--bg);
+ color: var(--text);
+ overflow: hidden;
+ height: 100vh;
+}
+
+#root {
+ height: 100vh;
+ display: flex;
+ flex-direction: column;
+}
+
+/* ─── Scanline overlay ────────────────────────────────────────────────────── */
+
+.scanline {
+ position: fixed;
+ inset: 0;
+ background: repeating-linear-gradient(
+ 0deg,
+ transparent,
+ transparent 2px,
+ rgba(0,0,0,0.03) 2px,
+ rgba(0,0,0,0.03) 4px
+ );
+ pointer-events: none;
+ z-index: 1000;
+}
+
+/* ─── Layout ──────────────────────────────────────────────────────────────── */
+
+.container {
+ display: flex;
+ flex-direction: column;
+ height: 100vh;
+ padding: 8px;
+ gap: 8px;
+}
+
+header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 8px 16px;
+ background: var(--panel-bg);
+ border: 1px solid var(--border);
+}
+
+header h1 {
+ font-size: 14px;
+ letter-spacing: 2px;
+ color: var(--accent);
+}
+
+main {
+ flex: 1;
+ display: grid;
+ grid-template-columns: 340px 1fr;
+ gap: 8px;
+ min-height: 0;
+}
+
+/* ─── Panels ──────────────────────────────────────────────────────────────── */
+
+.panel {
+ background: var(--panel-bg);
+ border: 1px solid var(--border);
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.panel-header {
+ font-size: 11px;
+ letter-spacing: 2px;
+ padding: 8px 12px;
+ background: rgba(255,149,0,0.05);
+ border-bottom: 1px solid var(--border);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.left-panel {
+ display: flex;
+ flex-direction: column;
+}
+
+.right-panel {
+ position: relative;
+}
+
+/* ─── Terminal ────────────────────────────────────────────────────────────── */
+
+.terminal {
+ flex: 1;
+ overflow-y: auto;
+ padding: 10px 12px;
+ font-size: 11px;
+ line-height: 1.6;
+ color: var(--text-dim);
+}
+
+.terminal div { word-break: break-all; }
+
+/* ─── Controls ────────────────────────────────────────────────────────────── */
+
+.controls {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ padding: 10px 12px;
+ border-top: 1px solid var(--border);
+}
+
+.hidden { display: none !important; }
+
+.cyber-btn {
+ font-family: var(--font-mono);
+ font-size: 11px;
+ letter-spacing: 1px;
+ padding: 8px 16px;
+ background: var(--btn-bg);
+ color: var(--text);
+ border: 1px solid var(--border);
+ cursor: pointer;
+ text-decoration: none;
+ text-align: center;
+ transition: background 0.2s, border-color 0.2s;
+}
+
+.cyber-btn:hover { background: var(--btn-hover); border-color: var(--accent); }
+.cyber-btn.disabled, .cyber-btn:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+
+.cyber-btn-icon {
+ font-family: var(--font-mono);
+ background: none;
+ border: none;
+ color: var(--text-dim);
+ cursor: pointer;
+ font-size: 14px;
+ padding: 2px 6px;
+}
+.cyber-btn-icon:hover { color: var(--accent); }
+
+/* ─── Status bar ──────────────────────────────────────────────────────────── */
+
+.status-bar {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 11px;
+ letter-spacing: 1px;
+ color: var(--text-dim);
+}
+
+.status-indicator {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: var(--text-dim);
+}
+.status-indicator.active { background: var(--accent); animation: pulse 1s infinite; }
+.status-indicator.complete { background: var(--success); }
+.status-indicator.error { background: var(--error); }
+
+@keyframes pulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.3; }
+}
+
+.blinking-cursor { animation: blink 1s step-end infinite; }
+@keyframes blink {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0; }
+}
+
+/* ─── Canvas / Path preview ───────────────────────────────────────────────── */
+
+.path-preview-container {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+}
+
+.canvas-container {
+ flex: 1;
+ position: relative;
+ min-height: 0;
+}
+
+.canvas-container canvas {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+}
+
+/* ─── Layer toggles ───────────────────────────────────────────────────────── */
+
+.layer-toggles {
+ display: flex;
+ gap: 12px;
+ padding: 6px 12px;
+ font-size: 10px;
+ letter-spacing: 1px;
+ border-top: 1px solid var(--border);
+}
+
+.layer-toggle {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ cursor: pointer;
+}
+
+.layer-toggle input { cursor: pointer; }
+
+/* ─── Stats overlay ───────────────────────────────────────────────────────── */
+
+.overlay-stats {
+ position: absolute;
+ top: 8px;
+ right: 8px;
+ background: rgba(10,14,20,0.85);
+ border: 1px solid var(--border);
+ padding: 10px 14px;
+ font-size: 10px;
+ letter-spacing: 1px;
+ line-height: 1.8;
+ pointer-events: none;
+}
+
+.stat-row { color: var(--text-dim); }
+.stat-divider { height: 1px; background: var(--border); margin: 4px 0; }
+.highlight { color: var(--accent); }
+
+/* ─── Settings modal ──────────────────────────────────────────────────────── */
+
+.settings-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(0,0,0,0.6);
+ z-index: 100;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+}
+
+.settings-panel {
+ background: var(--panel-bg);
+ border: 1px solid var(--border);
+ width: 440px;
+ max-height: 80vh;
+ display: flex;
+ flex-direction: column;
+}
+
+.settings-body {
+ overflow-y: auto;
+ padding: 12px;
+}
+
+.settings-group {
+ margin-bottom: 12px;
+}
+
+.settings-label {
+ font-size: 10px;
+ letter-spacing: 2px;
+ color: var(--accent);
+ margin-bottom: 6px;
+}
+
+.settings-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 4px 0;
+ font-size: 11px;
+}
+
+.settings-row label {
+ color: var(--text-dim);
+ font-size: 10px;
+ letter-spacing: 1px;
+}
+
+.settings-row-vertical {
+ flex-direction: column;
+ align-items: stretch;
+ gap: 4px;
+}
+
+.cyber-input, .cyber-textarea {
+ font-family: var(--font-mono);
+ font-size: 11px;
+ background: var(--input-bg);
+ border: 1px solid var(--border);
+ color: var(--text);
+ padding: 4px 8px;
+ width: 120px;
+}
+
+.cyber-textarea {
+ width: 100%;
+ resize: vertical;
+}
+
+.cyber-input:focus, .cyber-textarea:focus {
+ outline: none;
+ border-color: var(--accent);
+}
+
+.cyber-checkbox {
+ accent-color: var(--accent);
+}
+
+.settings-actions {
+ display: flex;
+ gap: 8px;
+ justify-content: flex-end;
+ margin-top: 8px;
+}
+
+/* ─── Scrollbar ───────────────────────────────────────────────────────────── */
+
+::-webkit-scrollbar { width: 6px; }
+::-webkit-scrollbar-track { background: var(--bg); }
+::-webkit-scrollbar-thumb { background: var(--border); }
+::-webkit-scrollbar-thumb:hover { background: var(--text-dim); }
diff --git a/app/frontend/src/main.tsx b/app/frontend/src/main.tsx
new file mode 100644
index 0000000..dfacde0
--- /dev/null
+++ b/app/frontend/src/main.tsx
@@ -0,0 +1,10 @@
+import { StrictMode } from 'react';
+import { createRoot } from 'react-dom/client';
+import App from './App';
+import './index.css';
+
+createRoot(document.getElementById('root')!).render(
+
+
+ ,
+);
diff --git a/app/frontend/src/store/appStore.ts b/app/frontend/src/store/appStore.ts
new file mode 100644
index 0000000..3fca377
--- /dev/null
+++ b/app/frontend/src/store/appStore.ts
@@ -0,0 +1,233 @@
+/** Central application store using Zustand. */
+
+import { create } from 'zustand';
+import type {
+ OptimizerSettings,
+ PathData,
+ OptPhase,
+ PreviewLayers,
+ WSEvent,
+} from '../types';
+import { DEFAULT_SETTINGS } from '../types';
+
+const SETTINGS_KEY = 'cyberplotter_settings';
+
+function loadPersistedSettings(): OptimizerSettings {
+ try {
+ const raw = localStorage.getItem(SETTINGS_KEY);
+ if (raw) return { ...DEFAULT_SETTINGS, ...JSON.parse(raw) };
+ } catch {
+ /* ignore */
+ }
+ return { ...DEFAULT_SETTINGS };
+}
+
+function persistSettings(s: OptimizerSettings): void {
+ localStorage.setItem(SETTINGS_KEY, JSON.stringify(s));
+}
+
+export interface StatsState {
+ originalDist: number;
+ phase1Dist: number;
+ finalDist: number;
+ gcodeDist: number;
+ iterations: number;
+ distHistory: number[];
+ filterRemoved: number;
+ filterKept: number;
+ removedIndices: number[];
+ mergeCount: number;
+}
+
+export interface AppState {
+ // ── Settings ────────────────────────────────────────────────────
+ settings: OptimizerSettings;
+ updateSettings: (partial: Partial) => void;
+ resetSettings: () => void;
+
+ // ── Job state ───────────────────────────────────────────────────
+ jobId: string | null;
+ phase: OptPhase;
+ logs: string[];
+ progress: number;
+ stats: StatsState;
+
+ // ── Path data / preview layers ──────────────────────────────────
+ layers: PreviewLayers;
+ visibleLayers: Record;
+ toggleLayer: (layer: keyof PreviewLayers) => void;
+
+ // ── Actions ─────────────────────────────────────────────────────
+ setJobId: (id: string) => void;
+ setPhase: (phase: OptPhase) => void;
+ addLog: (msg: string) => void;
+ setOriginalPaths: (paths: PathData[]) => void;
+ handleWSEvent: (event: WSEvent) => void;
+ reset: () => void;
+}
+
+const initialStats: StatsState = {
+ originalDist: 0,
+ phase1Dist: 0,
+ finalDist: 0,
+ gcodeDist: 0,
+ iterations: 0,
+ distHistory: [],
+ filterRemoved: 0,
+ filterKept: 0,
+ removedIndices: [],
+ mergeCount: 0,
+};
+
+const initialLayers: PreviewLayers = {
+ original: [],
+ filtered: null,
+ greedy: null,
+ merged: null,
+ twoopt: null,
+ final: null,
+};
+
+export const useAppStore = create((set, get) => ({
+ // ── Settings ────────────────────────────────────────────────────────
+ settings: loadPersistedSettings(),
+ updateSettings: (partial) => {
+ const next = { ...get().settings, ...partial };
+ persistSettings(next);
+ set({ settings: next });
+ },
+ resetSettings: () => {
+ const fresh = { ...DEFAULT_SETTINGS };
+ persistSettings(fresh);
+ set({ settings: fresh });
+ },
+
+ // ── Job state ───────────────────────────────────────────────────────
+ jobId: null,
+ phase: 'idle',
+ logs: [],
+ progress: 0,
+ stats: { ...initialStats },
+
+ // ── Preview layers ──────────────────────────────────────────────────
+ layers: { ...initialLayers },
+ visibleLayers: {
+ original: true,
+ filtered: false,
+ greedy: false,
+ merged: false,
+ twoopt: false,
+ final: true,
+ },
+ toggleLayer: (layer) =>
+ set((s) => ({
+ visibleLayers: { ...s.visibleLayers, [layer]: !s.visibleLayers[layer] },
+ })),
+
+ // ── Actions ─────────────────────────────────────────────────────────
+ setJobId: (id) => set({ jobId: id }),
+ setPhase: (phase) => set({ phase }),
+ addLog: (msg) => set((s) => ({ logs: [...s.logs, msg] })),
+ setOriginalPaths: (paths) =>
+ set({
+ layers: { ...initialLayers, original: paths },
+ phase: 'uploaded',
+ }),
+
+ handleWSEvent: (event) => {
+ const state = get();
+ switch (event.type) {
+ case 'log':
+ set({ logs: [...state.logs, event.msg] });
+ break;
+
+ case 'filter_start':
+ set({ phase: 'filtering' });
+ break;
+
+ case 'filter_result':
+ set({
+ stats: {
+ ...state.stats,
+ filterRemoved: event.removed_count,
+ filterKept: event.kept_count,
+ removedIndices: event.removed_indices,
+ },
+ });
+ break;
+
+ case 'greedy_result':
+ set({
+ phase: 'greedy',
+ layers: { ...state.layers, greedy: event.paths },
+ stats: {
+ ...state.stats,
+ originalDist: event.original_dist,
+ phase1Dist: event.phase1_dist,
+ gcodeDist: event.original_dist,
+ },
+ });
+ break;
+
+ case 'merge_result':
+ set({
+ phase: 'merging',
+ layers: { ...state.layers, merged: event.paths },
+ stats: {
+ ...state.stats,
+ mergeCount: event.merge_count,
+ phase1Dist: event.post_merge_dist,
+ },
+ });
+ break;
+
+ case 'twoopt_start':
+ set({ phase: 'twoopt' });
+ break;
+
+ case 'phase2_result':
+ set({
+ layers: {
+ ...state.layers,
+ twoopt: event.paths,
+ final: event.paths,
+ },
+ stats: {
+ ...state.stats,
+ iterations: event.iterations,
+ distHistory: event.dist_history,
+ finalDist: event.final_dist,
+ gcodeDist: event.gcode_dist,
+ phase1Dist: event.phase1_dist,
+ },
+ });
+ break;
+
+ case 'complete':
+ set({ phase: 'complete' });
+ break;
+
+ case 'phase_progress':
+ set({ progress: event.progress });
+ break;
+
+ case 'error':
+ set({ phase: 'error', logs: [...state.logs, `ERROR: ${event.msg}`] });
+ break;
+
+ case 'ping':
+ // keep-alive, no-op
+ break;
+ }
+ },
+
+ reset: () =>
+ set({
+ jobId: null,
+ phase: 'idle',
+ logs: [],
+ progress: 0,
+ stats: { ...initialStats },
+ layers: { ...initialLayers },
+ }),
+}));
diff --git a/app/frontend/src/store/index.ts b/app/frontend/src/store/index.ts
new file mode 100644
index 0000000..26de7d1
--- /dev/null
+++ b/app/frontend/src/store/index.ts
@@ -0,0 +1,2 @@
+export { useAppStore } from './appStore';
+export type { AppState, StatsState } from './appStore';
diff --git a/app/frontend/src/types.ts b/app/frontend/src/types.ts
new file mode 100644
index 0000000..9295bb6
--- /dev/null
+++ b/app/frontend/src/types.ts
@@ -0,0 +1,167 @@
+/** Shared type definitions matching backend v2 schemas. */
+
+export interface Point {
+ x: number;
+ y: number;
+}
+
+export type PathData = Point[];
+
+export interface OptimizerSettings {
+ z_up: number;
+ z_down: number;
+ feedrate: number;
+ travel_speed: number | null;
+ z_speed: number | null;
+ curve_tolerance: number;
+ pen_width: number;
+ visibility_threshold: number;
+ offset_closed_paths: boolean;
+ merge_threshold: number;
+ gcode_header: string;
+ gcode_footer: string;
+}
+
+export const DEFAULT_SETTINGS: OptimizerSettings = {
+ z_up: 2.0,
+ z_down: 0.0,
+ feedrate: 1000,
+ travel_speed: 3000,
+ z_speed: 500,
+ curve_tolerance: 0.1,
+ pen_width: 0,
+ visibility_threshold: 50,
+ offset_closed_paths: false,
+ merge_threshold: 0.5,
+ gcode_header: 'G28',
+ gcode_footer: 'G0 Z5\nG0 X10 Y10\nM84',
+};
+
+export interface UploadResponse {
+ job_id: string;
+ paths: PathData[];
+ source: string;
+ path_count: number;
+ settings: OptimizerSettings;
+}
+
+export interface JobStatusResponse {
+ job_id: string;
+ status: string;
+ path_count: number;
+ settings: OptimizerSettings;
+}
+
+// ── WebSocket event types ────────────────────────────────────────────────────
+
+export interface WSLogEvent {
+ type: 'log';
+ msg: string;
+}
+
+export interface WSFilterStartEvent {
+ type: 'filter_start';
+ path_count: number;
+ pen_width: number;
+ visibility_threshold: number;
+}
+
+export interface WSFilterResultEvent {
+ type: 'filter_result';
+ original_count: number;
+ removed_count: number;
+ kept_count: number;
+ removed_indices: number[];
+ pen_width: number;
+ visibility_threshold: number;
+}
+
+export interface WSGreedyResultEvent {
+ type: 'greedy_result';
+ paths: PathData[];
+ progress_history: number[];
+ original_dist: number;
+ phase1_dist: number;
+ path_count: number;
+}
+
+export interface WSMergeResultEvent {
+ type: 'merge_result';
+ paths: PathData[];
+ original_count: number;
+ merged_count: number;
+ merge_count: number;
+ post_merge_dist: number;
+}
+
+export interface WSTwoOptStartEvent {
+ type: 'twoopt_start';
+ estimated_paths: number;
+}
+
+export interface WSPhase2ResultEvent {
+ type: 'phase2_result';
+ iterations: number;
+ dist_history: number[];
+ paths: PathData[];
+ original_dist: number;
+ gcode_dist: number;
+ phase1_dist: number;
+ final_dist: number;
+}
+
+export interface WSCompleteEvent {
+ type: 'complete';
+ job_id: string;
+}
+
+export interface WSPingEvent {
+ type: 'ping';
+}
+
+export interface WSPhaseProgressEvent {
+ type: 'phase_progress';
+ phase: string;
+ progress: number;
+ detail?: string;
+}
+
+export interface WSErrorEvent {
+ type: 'error';
+ msg: string;
+}
+
+export type WSEvent =
+ | WSLogEvent
+ | WSFilterStartEvent
+ | WSFilterResultEvent
+ | WSGreedyResultEvent
+ | WSMergeResultEvent
+ | WSTwoOptStartEvent
+ | WSPhase2ResultEvent
+ | WSCompleteEvent
+ | WSPingEvent
+ | WSPhaseProgressEvent
+ | WSErrorEvent;
+
+// ── App state types ──────────────────────────────────────────────────────────
+
+export type OptPhase =
+ | 'idle'
+ | 'uploading'
+ | 'uploaded'
+ | 'filtering'
+ | 'greedy'
+ | 'merging'
+ | 'twoopt'
+ | 'complete'
+ | 'error';
+
+export interface PreviewLayers {
+ original: PathData[];
+ filtered: PathData[] | null;
+ greedy: PathData[] | null;
+ merged: PathData[] | null;
+ twoopt: PathData[] | null;
+ final: PathData[] | null;
+}
diff --git a/app/frontend/src/vite-env.d.ts b/app/frontend/src/vite-env.d.ts
new file mode 100644
index 0000000..11f02fe
--- /dev/null
+++ b/app/frontend/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/app/frontend/tsconfig.json b/app/frontend/tsconfig.json
new file mode 100644
index 0000000..5ea786e
--- /dev/null
+++ b/app/frontend/tsconfig.json
@@ -0,0 +1,25 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "isolatedModules": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedIndexedAccess": true,
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["src/*"]
+ }
+ },
+ "include": ["src"]
+}
diff --git a/app/frontend/vite.config.ts b/app/frontend/vite.config.ts
new file mode 100644
index 0000000..570f84b
--- /dev/null
+++ b/app/frontend/vite.config.ts
@@ -0,0 +1,25 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+import path from 'path'
+
+export default defineConfig({
+ plugins: [react()],
+ base: '/app-assets/',
+ build: {
+ outDir: 'dist',
+ assetsDir: 'assets',
+ },
+ resolve: {
+ alias: {
+ '@': path.resolve(__dirname, 'src'),
+ },
+ },
+ server: {
+ proxy: {
+ '/api/v2': 'http://localhost:8000',
+ '/upload': 'http://localhost:8000',
+ '/upload-svg': 'http://localhost:8000',
+ '/download': 'http://localhost:8000',
+ },
+ },
+})
diff --git a/app/frontend/vitest.config.ts b/app/frontend/vitest.config.ts
new file mode 100644
index 0000000..a2fa374
--- /dev/null
+++ b/app/frontend/vitest.config.ts
@@ -0,0 +1,18 @@
+///
+import { defineConfig } from 'vitest/config'
+import react from '@vitejs/plugin-react'
+import path from 'path'
+
+export default defineConfig({
+ plugins: [react()],
+ test: {
+ environment: 'jsdom',
+ globals: true,
+ setupFiles: ['./src/__tests__/setup.ts'],
+ },
+ resolve: {
+ alias: {
+ '@': path.resolve(__dirname, 'src'),
+ },
+ },
+})
diff --git a/app/main.py b/app/main.py
index c69b1d6..7e7c98a 100644
--- a/app/main.py
+++ b/app/main.py
@@ -1,9 +1,10 @@
import os
from fastapi import FastAPI
-from fastapi.responses import HTMLResponse
+from fastapi.responses import HTMLResponse, FileResponse
from fastapi.staticfiles import StaticFiles
from app.routers import gcode, svg as svg_router
+from app.routers import gcode_v2
BASE_PATH = os.getenv("BASE_PATH", "").rstrip("/")
@@ -24,10 +25,36 @@ async def __call__(self, scope, receive, send):
app.add_middleware(StripBasePath)
+# Legacy static files (index.html, script.js, style.css)
app.mount("/static", StaticFiles(directory="app/static"), name="static")
+
+# Legacy v1 routes (preserved for backward compatibility)
app.include_router(gcode.router)
app.include_router(svg_router.router)
+# Versioned v2 API routes
+app.include_router(gcode_v2.router)
+
+# Serve React SPA from app/frontend/dist if the build exists
+_frontend_dist = os.path.join(os.path.dirname(__file__), "frontend", "dist")
+_frontend_available = os.path.isdir(_frontend_dist)
+
+if _frontend_available:
+ app.mount("/app-assets", StaticFiles(directory=_frontend_dist), name="frontend-assets")
+
+
+@app.get("/v2")
+async def get_v2():
+ """Serve the React SPA entry point."""
+ index_path = os.path.join(_frontend_dist, "index.html")
+ if _frontend_available and os.path.exists(index_path):
+ return FileResponse(index_path)
+ return HTMLResponse(
+ "Frontend not built
Run npm run build in app/frontend first.
",
+ status_code=503,
+ )
+
+
@app.get("/")
async def get():
with open("app/static/index.html") as f:
diff --git a/app/models.py b/app/models.py
new file mode 100644
index 0000000..d50913d
--- /dev/null
+++ b/app/models.py
@@ -0,0 +1,130 @@
+"""Typed request/response/event schemas for the v2 API."""
+
+from __future__ import annotations
+
+from typing import List, Optional
+
+from pydantic import BaseModel, Field
+
+
+# ── Settings schema ──────────────────────────────────────────────────────────
+
+
+class OptimizerSettings(BaseModel):
+ z_up: float = Field(2.0, description="Pen lift height in mm")
+ z_down: float = Field(0.0, description="Pen down height in mm")
+ feedrate: float = Field(1000, description="Draw speed in mm/min")
+ travel_speed: Optional[float] = Field(3000, description="Travel speed in mm/min")
+ z_speed: Optional[float] = Field(500, description="Z-axis speed in mm/min")
+ curve_tolerance: float = Field(0.1, description="SVG curve discretization tolerance in mm")
+ pen_width: float = Field(0, description="Pen width in mm (0 to disable filter)")
+ visibility_threshold: float = Field(50, description="Min visibility % to keep a line")
+ offset_closed_paths: bool = Field(False, description="Offset closed paths inward by pen_width/2")
+ merge_threshold: float = Field(0.5, description="Max gap to merge adjacent paths in mm")
+ gcode_header: str = Field("G28", description="G-code header commands")
+ gcode_footer: str = Field("G0 Z5\nG0 X10 Y10\nM84", description="G-code footer commands")
+
+
+# ── Response schemas ─────────────────────────────────────────────────────────
+
+
+class Point(BaseModel):
+ x: float
+ y: float
+
+
+class UploadResponse(BaseModel):
+ job_id: str
+ paths: List[List[Point]]
+ source: str = "gcode"
+ path_count: int
+ settings: OptimizerSettings
+
+
+class JobStatusResponse(BaseModel):
+ job_id: str
+ status: str
+ path_count: int
+ settings: OptimizerSettings
+
+
+class ErrorResponse(BaseModel):
+ error: str
+ detail: Optional[str] = None
+
+
+# ── WebSocket event schemas ──────────────────────────────────────────────────
+
+
+class WSLogEvent(BaseModel):
+ type: str = "log"
+ msg: str
+
+
+class WSFilterStartEvent(BaseModel):
+ type: str = "filter_start"
+ path_count: int
+ pen_width: float
+ visibility_threshold: float
+
+
+class WSFilterResultEvent(BaseModel):
+ type: str = "filter_result"
+ original_count: int
+ removed_count: int
+ kept_count: int
+ removed_indices: List[int]
+ pen_width: float
+ visibility_threshold: float
+
+
+class WSGreedyResultEvent(BaseModel):
+ type: str = "greedy_result"
+ paths: List[List[Point]]
+ progress_history: list
+ original_dist: float
+ phase1_dist: float
+ path_count: int
+
+
+class WSMergeResultEvent(BaseModel):
+ type: str = "merge_result"
+ paths: List[List[Point]]
+ original_count: int
+ merged_count: int
+ merge_count: int
+ post_merge_dist: float
+
+
+class WSTwoOptStartEvent(BaseModel):
+ type: str = "twoopt_start"
+ estimated_paths: int
+
+
+class WSPhase2ResultEvent(BaseModel):
+ type: str = "phase2_result"
+ iterations: int
+ dist_history: list
+ paths: List[List[Point]]
+ original_dist: float
+ gcode_dist: float
+ phase1_dist: float
+ final_dist: float
+
+
+class WSCompleteEvent(BaseModel):
+ type: str = "complete"
+ job_id: str
+
+
+class WSPingEvent(BaseModel):
+ type: str = "ping"
+
+
+class WSPhaseProgressEvent(BaseModel):
+ """Incremental progress event for long-running phases."""
+
+ type: str = "phase_progress"
+ phase: str
+ progress: float = Field(description="0-100 progress percentage")
+ detail: Optional[str] = None
diff --git a/app/routers/gcode_v2.py b/app/routers/gcode_v2.py
new file mode 100644
index 0000000..4dc62ca
--- /dev/null
+++ b/app/routers/gcode_v2.py
@@ -0,0 +1,414 @@
+"""Versioned v2 API and WebSocket endpoints for G-code optimization.
+
+Provides the same functionality as the legacy endpoints but with typed
+request/response schemas and a ``/api/v2`` prefix. Legacy routes in
+``routers/gcode.py`` remain untouched for backward compatibility.
+"""
+
+import asyncio
+import concurrent.futures
+import json
+import uuid
+
+from fastapi import APIRouter, File, Form, UploadFile, WebSocket, WebSocketDisconnect
+from fastapi.responses import PlainTextResponse
+
+from app.models import (
+ OptimizerSettings,
+ UploadResponse,
+ JobStatusResponse,
+ ErrorResponse,
+)
+from app.optimizer import GcodeOptimizer
+from app.state import jobs
+from app.line_filter import apply_pen_filter
+
+router = APIRouter(prefix="/api/v2", tags=["v2"])
+
+
+# ── Helpers ───────────────────────────────────────────────────────────────────
+
+
+def _apply_settings(opt: GcodeOptimizer, settings: dict):
+ """Override optimizer parameters with user-provided settings."""
+ if "z_up" in settings:
+ opt.z_up = float(settings["z_up"])
+ if "z_down" in settings:
+ opt.z_down = float(settings["z_down"])
+ if "feedrate" in settings:
+ opt.feedrate = float(settings["feedrate"])
+ if "travel_speed" in settings:
+ opt.travel_speed = float(settings["travel_speed"]) if settings["travel_speed"] else None
+ if "z_speed" in settings:
+ opt.z_speed = float(settings["z_speed"]) if settings["z_speed"] else None
+ if "gcode_header" in settings:
+ opt.gcode_header = str(settings["gcode_header"])
+ if "gcode_footer" in settings:
+ opt.gcode_footer = str(settings["gcode_footer"])
+
+
+def _serialize_paths(paths):
+ return [[{"x": p[0], "y": p[1]} for p in path.points] for path in paths]
+
+
+# ── Upload ────────────────────────────────────────────────────────────────────
+
+
+@router.post("/upload", response_model=UploadResponse)
+async def upload_file(
+ file: UploadFile = File(...),
+ settings: str = Form(default="{}"),
+):
+ content = await file.read()
+ gcode_text = content.decode("utf-8")
+
+ user_settings = json.loads(settings)
+ parsed_settings = OptimizerSettings(**{**OptimizerSettings().model_dump(), **user_settings})
+
+ opt = GcodeOptimizer()
+ paths = opt.parse(gcode_text)
+ _apply_settings(opt, user_settings)
+
+ serializable_paths = _serialize_paths(paths)
+
+ job_id = str(uuid.uuid4())
+ jobs[job_id] = {
+ "status": "uploaded",
+ "original_gcode": gcode_text,
+ "optimized_gcode": None,
+ "paths": paths,
+ "settings": user_settings,
+ "z_up": opt.z_up,
+ "z_down": opt.z_down,
+ "feedrate": opt.feedrate,
+ "travel_speed": opt.travel_speed,
+ "z_speed": opt.z_speed,
+ "gcode_header": opt.gcode_header,
+ "gcode_footer": opt.gcode_footer,
+ }
+ return UploadResponse(
+ job_id=job_id,
+ paths=serializable_paths,
+ source="gcode",
+ path_count=len(paths),
+ settings=parsed_settings,
+ )
+
+
+# ── Upload SVG ────────────────────────────────────────────────────────────────
+
+
+@router.post("/upload-svg", response_model=UploadResponse)
+async def upload_svg(
+ file: UploadFile = File(...),
+ settings: str = Form(default="{}"),
+):
+ from app.svg_converter import convert_svg_to_gcode
+ from fastapi import HTTPException
+
+ content = await file.read()
+ user_settings = json.loads(settings)
+ parsed_settings = OptimizerSettings(**{**OptimizerSettings().model_dump(), **user_settings})
+
+ try:
+ gcode_text = convert_svg_to_gcode(content, user_settings)
+ except Exception as exc:
+ raise HTTPException(status_code=422, detail=f"SVG conversion failed: {exc}") from exc
+
+ opt = GcodeOptimizer()
+ paths = opt.parse(gcode_text)
+ _apply_settings(opt, user_settings)
+
+ if not paths:
+ raise HTTPException(
+ status_code=422,
+ detail="No drawable paths found in SVG.",
+ )
+
+ serializable_paths = _serialize_paths(paths)
+
+ job_id = str(uuid.uuid4())
+ jobs[job_id] = {
+ "status": "uploaded",
+ "original_gcode": gcode_text,
+ "optimized_gcode": None,
+ "paths": paths,
+ "settings": user_settings,
+ "z_up": opt.z_up,
+ "z_down": opt.z_down,
+ "feedrate": opt.feedrate,
+ "travel_speed": opt.travel_speed,
+ "z_speed": opt.z_speed,
+ "gcode_header": opt.gcode_header,
+ "gcode_footer": opt.gcode_footer,
+ }
+ return UploadResponse(
+ job_id=job_id,
+ paths=serializable_paths,
+ source="svg",
+ path_count=len(paths),
+ settings=parsed_settings,
+ )
+
+
+# ── Job status ────────────────────────────────────────────────────────────────
+
+
+@router.get("/job/{job_id}", response_model=JobStatusResponse)
+async def get_job(job_id: str):
+ from fastapi import HTTPException
+
+ if job_id not in jobs:
+ raise HTTPException(status_code=404, detail="Job not found")
+ job = jobs[job_id]
+ settings = OptimizerSettings(**{**OptimizerSettings().model_dump(), **job.get("settings", {})})
+ return JobStatusResponse(
+ job_id=job_id,
+ status=job["status"],
+ path_count=len(job["paths"]),
+ settings=settings,
+ )
+
+
+# ── Download ──────────────────────────────────────────────────────────────────
+
+
+@router.get("/download/{job_id}")
+async def download(job_id: str):
+ from fastapi import HTTPException
+
+ if job_id not in jobs:
+ raise HTTPException(status_code=404, detail="Job not found")
+ job = jobs[job_id]
+ if not job.get("optimized_gcode"):
+ raise HTTPException(status_code=400, detail="Job not completed")
+ return PlainTextResponse(
+ job["optimized_gcode"],
+ headers={
+ "Content-Disposition": f"attachment; filename=optimized_{job_id[:8]}.gcode"
+ },
+ )
+
+
+# ── Settings schema endpoint ─────────────────────────────────────────────────
+
+
+@router.get("/settings/schema")
+async def get_settings_schema():
+ """Return the JSON Schema for optimizer settings."""
+ return OptimizerSettings.model_json_schema()
+
+
+# ── WebSocket v2 ──────────────────────────────────────────────────────────────
+
+
+@router.websocket("/ws/{job_id}")
+async def websocket_endpoint(websocket: WebSocket, job_id: str):
+ await websocket.accept()
+ if job_id not in jobs:
+ await websocket.send_json({"type": "error", "msg": "Job not found"})
+ await websocket.close()
+ return
+
+ job = jobs[job_id]
+ opt = GcodeOptimizer()
+ opt.z_up = job["z_up"]
+ opt.z_down = job["z_down"]
+ opt.feedrate = job["feedrate"]
+ opt.travel_speed = job["travel_speed"]
+ opt.z_speed = job["z_speed"]
+ opt.gcode_header = job["gcode_header"]
+ opt.gcode_footer = job["gcode_footer"]
+ paths = job["paths"]
+
+ await websocket.send_json({"type": "log", "msg": "Initializing PlotterTool Core..."})
+ await asyncio.sleep(0.5)
+
+ await websocket.send_json(
+ {
+ "type": "log",
+ "msg": f"Loaded {len(paths)} paths. Z-Down: {opt.z_down}, Z-Up: {opt.z_up}",
+ }
+ )
+
+ # ── Line filter phase ────────────────────────────────────────────────────
+ settings = job.get("settings", {})
+ pw = float(settings.get("pen_width", 0))
+ if pw > 0:
+ vt = float(settings.get("visibility_threshold", 50))
+ await websocket.send_json(
+ {"type": "log", "msg": "Initializing coverage analysis subsystem..."}
+ )
+ await websocket.send_json(
+ {
+ "type": "filter_start",
+ "path_count": len(paths),
+ "pen_width": pw,
+ "visibility_threshold": vt,
+ }
+ )
+
+ filtered, filter_stats = apply_pen_filter(paths, settings)
+ removed_count = filter_stats["removed_count"] if filter_stats else 0
+
+ await websocket.send_json(
+ {
+ "type": "filter_result",
+ "original_count": len(paths),
+ "removed_count": removed_count,
+ "kept_count": len(filtered),
+ "removed_indices": filter_stats["removed_indices"] if filter_stats else [],
+ "pen_width": pw,
+ "visibility_threshold": vt,
+ }
+ )
+
+ paths = filtered
+ job["paths"] = paths
+
+ await websocket.send_json(
+ {
+ "type": "log",
+ "msg": f"Coverage scan: {removed_count} paths eliminated, {len(paths)} remaining",
+ }
+ )
+
+ await websocket.send_json(
+ {"type": "log", "msg": "Deploying Greedy Nearest-Neighbor heuristic..."}
+ )
+
+ # ── Phase 1: Greedy Sort ─────────────────────────────────────────────────
+ original_dist = opt.calculate_penup_distance(paths)
+ greedy_paths, greedy_history = opt.greedy_sort(paths)
+ phase1_dist = opt.calculate_penup_distance(greedy_paths)
+
+ greedy_paths_data = _serialize_paths(greedy_paths)
+
+ await websocket.send_json(
+ {
+ "type": "greedy_result",
+ "paths": greedy_paths_data,
+ "progress_history": greedy_history,
+ "original_dist": original_dist,
+ "phase1_dist": phase1_dist,
+ "path_count": len(greedy_paths),
+ }
+ )
+
+ await websocket.send_json(
+ {
+ "type": "log",
+ "msg": f"Greedy sort complete: {original_dist:.1f}mm → {phase1_dist:.1f}mm travel",
+ }
+ )
+
+ # ── Path Merging (optional) ──────────────────────────────────────────────
+ merge_threshold = float(settings.get("merge_threshold", 0))
+ paths_to_optimize = greedy_paths
+
+ if merge_threshold > 0:
+ await websocket.send_json(
+ {"type": "log", "msg": f"Merging adjacent paths (threshold: {merge_threshold:.2f}mm)..."}
+ )
+
+ merged_paths, merge_stats = opt.merge_adjacent_paths(greedy_paths, merge_threshold)
+
+ if merge_stats["merge_count"] > 0:
+ post_merge_dist = opt.calculate_penup_distance(merged_paths)
+
+ merged_paths_data = _serialize_paths(merged_paths)
+
+ await websocket.send_json(
+ {
+ "type": "merge_result",
+ "paths": merged_paths_data,
+ "original_count": merge_stats["original_count"],
+ "merged_count": merge_stats["merged_count"],
+ "merge_count": merge_stats["merge_count"],
+ "post_merge_dist": post_merge_dist,
+ }
+ )
+
+ await websocket.send_json(
+ {
+ "type": "log",
+ "msg": f"Merged {merge_stats['merge_count']} path pairs: {merge_stats['original_count']} → {merge_stats['merged_count']} paths",
+ }
+ )
+
+ paths_to_optimize = merged_paths
+ phase1_dist = post_merge_dist
+ else:
+ await websocket.send_json(
+ {"type": "log", "msg": "No paths within merge threshold"}
+ )
+
+ # ── Phase 2: 2-OPT ───────────────────────────────────────────────────────
+ await websocket.send_json(
+ {"type": "log", "msg": "Starting 2-Opt refinement..."}
+ )
+
+ await websocket.send_json({"type": "twoopt_start", "estimated_paths": len(paths_to_optimize)})
+
+ loop = asyncio.get_event_loop()
+
+ with concurrent.futures.ThreadPoolExecutor() as executor:
+ future = loop.run_in_executor(executor, opt.two_opt_sync, paths_to_optimize)
+
+ while not future.done():
+ try:
+ await asyncio.wait_for(asyncio.shield(future), timeout=5.0)
+ except asyncio.TimeoutError:
+ try:
+ await websocket.send_json({"type": "ping"})
+ except Exception:
+ pass
+
+ optimized_paths, twoopt_stats = await future
+
+ iterations = twoopt_stats["iterations"]
+ full_history = twoopt_stats["dist_history"]
+ final_dist = twoopt_stats["final_dist"]
+
+ savings_pct = 0
+ if phase1_dist > 0:
+ savings_pct = (1 - final_dist / phase1_dist) * 100
+
+ await websocket.send_json(
+ {
+ "type": "log",
+ "msg": f"2-Opt converged: {iterations} iterations",
+ }
+ )
+ await websocket.send_json(
+ {
+ "type": "log",
+ "msg": (
+ f"Travel: {original_dist:.1f}mm (gcode) → "
+ f"{phase1_dist:.1f}mm (NN) → {final_dist:.1f}mm "
+ f"({savings_pct:.1f}% NN refinement)"
+ ),
+ }
+ )
+
+ paths_data = _serialize_paths(optimized_paths)
+
+ await websocket.send_json(
+ {
+ "type": "phase2_result",
+ "iterations": iterations,
+ "dist_history": full_history,
+ "paths": paths_data,
+ "original_dist": phase1_dist,
+ "gcode_dist": original_dist,
+ "phase1_dist": phase1_dist,
+ "final_dist": final_dist,
+ }
+ )
+
+ await websocket.send_json({"type": "log", "msg": "Generating optimized G-code..."})
+ final_gcode = opt.generate(optimized_paths)
+ job["optimized_gcode"] = final_gcode
+ job["status"] = "completed"
+
+ await websocket.send_json({"type": "complete", "job_id": job_id})
diff --git a/app/test_v2_api.py b/app/test_v2_api.py
new file mode 100644
index 0000000..da2e8c4
--- /dev/null
+++ b/app/test_v2_api.py
@@ -0,0 +1,148 @@
+"""Contract-focused tests for v2 API endpoints.
+
+Validates typed request/response shapes and backward compatibility.
+"""
+
+import io
+import pytest
+from fastapi.testclient import TestClient
+
+from app.main import app
+from app.state import jobs
+
+client = TestClient(app)
+
+# Sample minimal G-code file
+SAMPLE_GCODE = """\
+G28
+G0 Z2
+G0 X10 Y10
+G0 Z0
+G1 X20 Y20 F1000
+G1 X30 Y10
+G0 Z2
+G0 X50 Y50
+G0 Z0
+G1 X60 Y60 F1000
+G1 X70 Y50
+G0 Z2
+"""
+
+
+def _upload_gcode(endpoint: str = "/api/v2/upload"):
+ """Helper to upload sample G-code and return response JSON."""
+ jobs.clear()
+ return client.post(
+ endpoint,
+ files={"file": ("test.gcode", io.BytesIO(SAMPLE_GCODE.encode()), "text/plain")},
+ data={"settings": '{"z_up": 2, "z_down": 0, "feedrate": 1000}'},
+ )
+
+
+class TestV2Upload:
+ def test_upload_returns_typed_response(self):
+ resp = _upload_gcode()
+ assert resp.status_code == 200
+ body = resp.json()
+ assert "job_id" in body
+ assert "paths" in body
+ assert "path_count" in body
+ assert "settings" in body
+ assert "source" in body
+ assert body["source"] == "gcode"
+ assert isinstance(body["paths"], list)
+ assert body["path_count"] == len(body["paths"])
+
+ def test_upload_settings_shape(self):
+ resp = _upload_gcode()
+ body = resp.json()
+ settings = body["settings"]
+ assert "z_up" in settings
+ assert "z_down" in settings
+ assert "feedrate" in settings
+ assert "pen_width" in settings
+ assert "merge_threshold" in settings
+ assert "gcode_header" in settings
+ assert "gcode_footer" in settings
+
+ def test_upload_paths_point_shape(self):
+ resp = _upload_gcode()
+ body = resp.json()
+ for path in body["paths"]:
+ assert isinstance(path, list)
+ for point in path:
+ assert "x" in point
+ assert "y" in point
+ assert isinstance(point["x"], (int, float))
+ assert isinstance(point["y"], (int, float))
+
+
+class TestV2JobStatus:
+ def test_get_job_exists(self):
+ resp = _upload_gcode()
+ job_id = resp.json()["job_id"]
+ status_resp = client.get(f"/api/v2/job/{job_id}")
+ assert status_resp.status_code == 200
+ body = status_resp.json()
+ assert body["job_id"] == job_id
+ assert body["status"] == "uploaded"
+ assert "path_count" in body
+ assert "settings" in body
+
+ def test_get_job_not_found(self):
+ jobs.clear()
+ resp = client.get("/api/v2/job/nonexistent-id")
+ assert resp.status_code == 404
+
+
+class TestV2Download:
+ def test_download_not_completed(self):
+ resp = _upload_gcode()
+ job_id = resp.json()["job_id"]
+ dl = client.get(f"/api/v2/download/{job_id}")
+ assert dl.status_code == 400
+
+ def test_download_not_found(self):
+ jobs.clear()
+ resp = client.get("/api/v2/download/nonexistent-id")
+ assert resp.status_code == 404
+
+
+class TestV2SettingsSchema:
+ def test_schema_endpoint(self):
+ resp = client.get("/api/v2/settings/schema")
+ assert resp.status_code == 200
+ schema = resp.json()
+ assert "properties" in schema
+ props = schema["properties"]
+ assert "z_up" in props
+ assert "feedrate" in props
+ assert "pen_width" in props
+
+
+class TestLegacyBackwardCompatibility:
+ """Verify existing v1 endpoints still work."""
+
+ def test_legacy_upload(self):
+ jobs.clear()
+ resp = client.post(
+ "/upload",
+ files={"file": ("test.gcode", io.BytesIO(SAMPLE_GCODE.encode()), "text/plain")},
+ data={"settings": '{"z_up": 2, "z_down": 0}'},
+ )
+ assert resp.status_code == 200
+ body = resp.json()
+ assert "job_id" in body
+ assert "paths" in body
+
+ def test_legacy_download_not_found(self):
+ jobs.clear()
+ resp = client.get("/download/nonexistent-id")
+ assert resp.status_code == 200 # Legacy returns JSON error, not 404
+ body = resp.json()
+ assert "error" in body
+
+ def test_legacy_root_serves_html(self):
+ resp = client.get("/")
+ assert resp.status_code == 200
+ assert "PLOTTERTOOL" in resp.text