From 2de5ee08bff25d2156e4394dea6a0d7a66e663d7 Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Thu, 9 Jul 2026 13:21:09 +0200 Subject: [PATCH 01/27] dotbot/console: add the unified web console app AI-assisted: Claude Fable 5 --- dotbot/console/index.html | 18 + dotbot/console/package-lock.json | 1732 ++++++++++++++++++++++++++++++ dotbot/console/package.json | 22 + dotbot/console/src/App.tsx | 295 +++++ dotbot/console/src/Footer.tsx | 313 ++++++ dotbot/console/src/Joystick.tsx | 136 +++ dotbot/console/src/MapView.tsx | 505 +++++++++ dotbot/console/src/api.ts | 64 ++ dotbot/console/src/main.tsx | 11 + dotbot/console/src/tokens.css | 54 + dotbot/console/src/types.ts | 79 ++ dotbot/console/src/useFleet.ts | 173 +++ dotbot/console/tsconfig.json | 20 + dotbot/console/vite.config.ts | 28 + 14 files changed, 3450 insertions(+) create mode 100644 dotbot/console/index.html create mode 100644 dotbot/console/package-lock.json create mode 100644 dotbot/console/package.json create mode 100644 dotbot/console/src/App.tsx create mode 100644 dotbot/console/src/Footer.tsx create mode 100644 dotbot/console/src/Joystick.tsx create mode 100644 dotbot/console/src/MapView.tsx create mode 100644 dotbot/console/src/api.ts create mode 100644 dotbot/console/src/main.tsx create mode 100644 dotbot/console/src/tokens.css create mode 100644 dotbot/console/src/types.ts create mode 100644 dotbot/console/src/useFleet.ts create mode 100644 dotbot/console/tsconfig.json create mode 100644 dotbot/console/vite.config.ts diff --git a/dotbot/console/index.html b/dotbot/console/index.html new file mode 100644 index 00000000..9d8975e0 --- /dev/null +++ b/dotbot/console/index.html @@ -0,0 +1,18 @@ + + + + + + DotBot Console + + + + + +
+ + + diff --git a/dotbot/console/package-lock.json b/dotbot/console/package-lock.json new file mode 100644 index 00000000..275a2ac0 --- /dev/null +++ b/dotbot/console/package-lock.json @@ -0,0 +1,1732 @@ +{ + "name": "dotbot-console", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "dotbot-console", + "version": "0.0.1", + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@types/react": "^18.2.43", + "@types/react-dom": "^18.2.17", + "@vitejs/plugin-react": "^4.2.1", + "typescript": "^5.2.2", + "vite": "^5.0.8" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@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.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@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.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "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.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "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/@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/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "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/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.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/baseline-browser-mapping": { + "version": "2.10.42", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.42.tgz", + "integrity": "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.5", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.5.tgz", + "integrity": "sha512-Cu2E6QejHWzuDMTkuwgpABFgDfZrXLQq5V13YOACZx4mFAG4IwGTbTfHPMr4WtxlHoXSM8FIuRwYYCz5XiabaQ==", + "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.10.42", + "caniuse-lite": "^1.0.30001800", + "electron-to-chromium": "^1.5.387", + "node-releases": "^2.0.50", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001803", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001803.tgz", + "integrity": "sha512-g/uHREV2ZpK9qMalCsWaxmA6ol+DX8GYhuf3T40RKoP+oL7vhRJh8LNt73PCjpnR6l14FzfPrB5Yux4PKm2meg==", + "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/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/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/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/electron-to-chromium": { + "version": "1.5.389", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.389.tgz", + "integrity": "sha512-cEto7aeOqBfU1D+c5py5pE+ooscKE75JifxLBdFUZsqAxRS6y7kebtxAZvICszSl05gPjYHDTjY+lXpyGvpJbg==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "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/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/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "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/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "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/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.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "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.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "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/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "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.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "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/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "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/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/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": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "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" + } + } +} diff --git a/dotbot/console/package.json b/dotbot/console/package.json new file mode 100644 index 00000000..927dfd9e --- /dev/null +++ b/dotbot/console/package.json @@ -0,0 +1,22 @@ +{ + "name": "dotbot-console", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "start": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@types/react": "^18.2.43", + "@types/react-dom": "^18.2.17", + "@vitejs/plugin-react": "^4.2.1", + "typescript": "^5.2.2", + "vite": "^5.0.8" + } +} diff --git a/dotbot/console/src/App.tsx b/dotbot/console/src/App.tsx new file mode 100644 index 00000000..c2fe48f2 --- /dev/null +++ b/dotbot/console/src/App.tsx @@ -0,0 +1,295 @@ +import React, { useCallback, useState } from "react"; + +import { putWaypoints } from "./api"; +import { Footer } from "./Footer"; +import { Layers, MapView } from "./MapView"; +import { LH2Position } from "./types"; +import { useFleet } from "./useFleet"; + +const WAYPOINT_THRESHOLD = 60; // mm, arrival radius sent with waypoint missions + +export const App: React.FC = () => { + const { bots, mapSize, wsUp } = useFleet(); + const [theme, setTheme] = useState<"dark" | "light">("dark"); + // ?sel=[,] preselects bots (handy for dev/screenshots). + const [selection, setSelection] = useState>(new Set()); + const preselRef = React.useRef(false); + React.useEffect(() => { + if (preselRef.current || bots.length === 0) return; + const raw = new URLSearchParams(window.location.search).get("sel"); + if (raw) { + const suffixes = raw.toLowerCase().split(","); + const hits = bots + .filter((b) => suffixes.some((s) => b.id.toLowerCase().endsWith(s))) + .map((b) => b.id); + if (hits.length) setSelection(new Set(hits)); + } + preselRef.current = true; + }, [bots]); + const [pending, setPending] = useState([]); + const [layersOpen, setLayersOpen] = useState(false); + const [layers, setLayers] = useState({ + batteryBars: true, + waypoints: true, + trails: false, + labels: true, + }); + + const onSelect = useCallback((ids: string[], additive: boolean) => { + setSelection((prev) => { + if (!additive) return new Set(ids); + const next = new Set(prev); + ids.forEach((id) => (next.has(id) ? next.delete(id) : next.add(id))); + return next; + }); + if (!additive && ids.length === 0) setPending([]); + }, []); + + const selectedBots = bots.filter((b) => selection.has(b.id)); + const drivableSelected = selectedBots.filter((b) => b.drivable); + + const onAddWaypoint = useCallback( + (p: LH2Position) => { + if (drivableSelected.length === 0) return; + setPending((prev) => [...prev, p]); + }, + [drivableSelected.length], + ); + + const onGo = useCallback(() => { + if (pending.length === 0) return; + drivableSelected.forEach((b) => { + putWaypoints(b.id, b.application, WAYPOINT_THRESHOLD, pending).catch(() => {}); + }); + setPending([]); + }, [pending, drivableSelected]); + + const onClearWaypoints = useCallback(() => { + setPending([]); + drivableSelected.forEach((b) => { + putWaypoints(b.id, b.application, WAYPOINT_THRESHOLD, []).catch(() => {}); + }); + }, [drivableSelected]); + + const layerRows: { key: keyof Layers; label: string }[] = [ + { key: "batteryBars", label: "Battery bars" }, + { key: "waypoints", label: "Waypoints" }, + { key: "trails", label: "Trails" }, + { key: "labels", label: "Labels" }, + ]; + + return ( +
+ {/* Title bar */} +
+
DotBots
+
+ + dotbot.local:8000 + +
+
+ + {wsUp ? "LIVE" : "OFFLINE"} + + · {bots.length} bots +
+
+ {/* theme toggle */} +
setTheme((t) => (t === "dark" ? "light" : "dark"))} + title="Toggle theme" + style={{ + position: "relative", + width: 48, + height: 26, + borderRadius: 13, + cursor: "pointer", + background: "var(--elevated)", + border: "1px solid var(--hairline)", + flex: "none", + }} + > + + ☀ + + + ☽ + +
+
+
+ + {/* Map area with view switcher + layers */} +
+ + + {/* switcher (Map wired; List/Grid land with the next iteration) */} +
+
setLayersOpen((v) => !v)} + style={{ + display: "flex", + alignItems: "center", + gap: 6, + padding: "7px 11px", + borderRadius: 8, + cursor: "pointer", + fontSize: 12, + background: layersOpen ? "var(--elevated)" : "var(--surface)", + border: "1px solid var(--hairline)", + boxShadow: "0 4px 16px rgba(0,0,0,.3)", + }} + > + ▤ Layers +
+
+ {["Map", "List", "Grid"].map((v, i) => ( +
0 ? "Coming in the next iteration" : undefined} + style={{ + padding: "6px 14px", + borderRadius: 6, + fontSize: 12, + cursor: i === 0 ? "default" : "not-allowed", + background: i === 0 ? "var(--accent)" : "transparent", + color: i === 0 ? "#fff" : "var(--muted)", + fontWeight: i === 0 ? 600 : 400, + opacity: i === 0 ? 1 : 0.6, + }} + > + {v} +
+ ))} +
+
+ + {/* layers panel */} + {layersOpen && ( +
+ {layerRows.map((l) => ( +
setLayers((prev) => ({ ...prev, [l.key]: !prev[l.key] }))} + style={{ + display: "flex", + alignItems: "center", + justifyContent: "space-between", + padding: "5px 4px", + borderRadius: 5, + cursor: "pointer", + fontSize: 12, + }} + > + {l.label} + + {layers[l.key] ? "✓" : ""} + +
+ ))} +
+ )} +
+ +
onSelect(ids, false)} + onGo={onGo} + onClearWaypoints={onClearWaypoints} + /> +
+ ); +}; diff --git a/dotbot/console/src/Footer.tsx b/dotbot/console/src/Footer.tsx new file mode 100644 index 00000000..a8adf5ba --- /dev/null +++ b/dotbot/console/src/Footer.tsx @@ -0,0 +1,313 @@ +import React, { useState } from "react"; + +import { putRgbLed } from "./api"; +import { Joystick } from "./Joystick"; +import { LH2Position, MapSize, STATE_ORDER, UnifiedBot } from "./types"; + +const SWATCHES: [number, number, number][] = [ + [228, 3, 46], + [255, 140, 0], + [255, 200, 0], + [34, 197, 94], + [13, 148, 136], + [56, 189, 248], + [64, 80, 230], + [168, 85, 247], + [255, 255, 255], + [60, 60, 60], +]; + +const label = { fontSize: 10, letterSpacing: ".5px", textTransform: "uppercase", color: "var(--muted)" } as const; +const mono = { fontFamily: "var(--font-mono)" } as const; + +interface FooterProps { + bots: UnifiedBot[]; + mapSize: MapSize; + selection: Set; + pendingWaypoints: LH2Position[]; + onSelectState: (ids: string[]) => void; + onGo: () => void; + onClearWaypoints: () => void; +} + +const StateDot: React.FC<{ state: string }> = ({ state }) => ( + +); + +const BatteryBar: React.FC<{ volts: number }> = ({ volts }) => { + const pct = Math.max(0, Math.min(100, ((volts - 2.0) / (4.2 - 2.0)) * 100)); + return ( +
+
+
+
+ {volts.toFixed(2)} V +
+ ); +}; + +// The control dock: joystick + LED + waypoint actions, shared by the +// one-selected and multi-selected panels (same dock, single vs group). +const ControlDock: React.FC<{ + targets: UnifiedBot[]; + pending: number; + onGo: () => void; + onClear: () => void; +}> = ({ targets, pending, onGo, onClear }) => { + const [ledOpen, setLedOpen] = useState(false); + const drivable = targets.filter((b) => b.drivable); + const btn = { + display: "flex", + alignItems: "center", + gap: 7, + padding: "6px 12px", + borderRadius: 7, + cursor: "pointer", + background: "var(--elevated)", + border: "1px solid var(--hairline)", + fontSize: 12, + } as const; + return ( +
+
Control
+
+ +
+
drivable.length && setLedOpen((v) => !v)} style={{ ...btn, opacity: drivable.length ? 1 : 0.4 }}> +
+ LED{targets.length > 1 ? " all" : ""} +
+
+
pending > 0 && drivable.length && onGo()} + style={{ + ...btn, + background: pending > 0 && drivable.length ? "var(--accent)" : "var(--elevated)", + color: pending > 0 && drivable.length ? "#fff" : "var(--muted)", + fontWeight: 600, + }} + > + ▶ Go{pending > 0 ? ` (${pending})` : ""} +
+
+ Clear +
+
+
+
+
+ {drivable.length === 0 + ? "Not drivable - no DBP-speaking firmware running." + : `⌥ Alt-click the map to queue waypoints · Go sends · joystick drives ${ + drivable.length > 1 ? `${drivable.length} bots` : "the bot" + }.`} +
+ {ledOpen && ( +
+
+ LED color{targets.length > 1 ? ` · ${drivable.length} bots` : ""} +
+
+ {SWATCHES.map(([r, g, b], i) => ( +
{ + drivable.forEach((bot) => + putRgbLed(bot.id, bot.application, { red: r, green: g, blue: b }).catch(() => {}), + ); + setLedOpen(false); + }} + style={{ + width: 22, + height: 22, + borderRadius: "50%", + background: `rgb(${r},${g},${b})`, + cursor: "pointer", + border: "1px solid var(--hairline)", + }} + /> + ))} +
+
+ )} +
+ ); +}; + +const Sep: React.FC = () =>
; + +export const Footer: React.FC = (props) => { + const selected = props.bots.filter((b) => props.selection.has(b.id)); + + const rollup = STATE_ORDER.map((s) => ({ + state: s, + ids: props.bots.filter((b) => b.state === s).map((b) => b.id), + })).filter((r) => r.ids.length > 0); + + return ( +
+ {/* fleet strip content */} +
+ {selected.length === 0 && ( +
+
+
+ {props.bots.length} + / 1000 +
+
DotBots online
+
+ +
+ {rollup.map((r) => ( +
props.onSelectState(r.ids)} + title="Select these bots" + style={{ display: "flex", alignItems: "center", gap: 8, cursor: "pointer" }} + > + + {r.ids.length} + {r.state} +
+ ))} +
+
+ )} + + {selected.length === 1 && ( +
+
+
+
+ {selected[0].id.slice(-4)} +
+ {selected[0].id} + {selected[0].deviceType} + + ◎ {selected[0].drivable ? "drivable" : "not drivable"} + +
+ +
+
Status
+
+ + {selected[0].state} +
+
+ + + {selected[0].position ? `${Math.round(selected[0].position.x)}, ${Math.round(selected[0].position.y)}` : "-"} + +
+
+ + +
+
+ )} + + {selected.length > 1 && ( +
+
+ {selected.length} + selected + props.onSelectState([])} style={{ fontSize: 11, color: "var(--accent)", cursor: "pointer" }}> + Clear selection + +
+ +
+
Status
+
+ {STATE_ORDER.map((s) => { + const n = selected.filter((b) => b.state === s).length; + return n > 0 ? ( +
+ + {n} + {s} +
+ ) : null; + })} +
+
+ + +
+
+ )} +
+
+ ); +}; diff --git a/dotbot/console/src/Joystick.tsx b/dotbot/console/src/Joystick.tsx new file mode 100644 index 00000000..20c14fd3 --- /dev/null +++ b/dotbot/console/src/Joystick.tsx @@ -0,0 +1,136 @@ +import React, { useEffect, useRef, useState } from "react"; + +import { putMoveRaw } from "./api"; +import { UnifiedBot } from "./types"; + +// Same differential-drive mapping as the classic frontend Joystick: +// dir = -128*y/200, angle = 128*x/200, left = dir+angle, right = dir-angle, +// +/- deadband offset, clamped to [-128, 127]. Published at 10 Hz while held +// (the firmware's ~520 ms deadman stops motors when we stop publishing). +const SPEED_OFFSET = 30; +const PAD = 64; // px, pad size +const R = 22; // px, knob travel radius + +function speeds(dx: number, dy: number): { left: number; right: number } { + // Scale pad offsets to the reference 200 px frame of the original math. + const px = (dx / R) * 100; + const py = (dy / R) * 100; + const dir = (128 * py / 200) * -1; + const angle = (128 * px) / 200; + let left = dir + angle; + let right = dir - angle; + if (left > 0) left += SPEED_OFFSET; + if (left < 0) left -= SPEED_OFFSET; + if (right > 0) right += SPEED_OFFSET; + if (right < 0) right -= SPEED_OFFSET; + return { + left: Math.max(-128, Math.min(127, Math.trunc(left))), + right: Math.max(-128, Math.min(127, Math.trunc(right))), + }; +} + +interface JoystickProps { + targets: UnifiedBot[]; // drivable bots to drive together +} + +export const Joystick: React.FC = ({ targets }) => { + const [knob, setKnob] = useState({ x: 0, y: 0 }); + const [active, setActive] = useState(false); + const knobRef = useRef(knob); + knobRef.current = knob; + const targetsRef = useRef(targets); + targetsRef.current = targets; + const enabled = targets.length > 0; + + useEffect(() => { + if (!active) return; + const t = setInterval(() => { + const { left, right } = speeds(knobRef.current.x, knobRef.current.y); + targetsRef.current.forEach((b) => { + putMoveRaw(b.id, b.application, left, right).catch(() => {}); + }); + }, 100); + return () => clearInterval(t); + }, [active]); + + const stop = () => { + setActive(false); + setKnob({ x: 0, y: 0 }); + targetsRef.current.forEach((b) => { + putMoveRaw(b.id, b.application, 0, 0).catch(() => {}); + }); + }; + + const move = (e: React.PointerEvent) => { + const r = (e.currentTarget as HTMLElement).getBoundingClientRect(); + let dx = e.clientX - (r.left + r.width / 2); + let dy = e.clientY - (r.top + r.height / 2); + const len = Math.hypot(dx, dy); + if (len > R) { + dx = (dx / len) * R; + dy = (dy / len) * R; + } + setKnob({ x: dx, y: dy }); + }; + + return ( +
{ + if (!enabled) return; + setActive(true); + (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); + move(e); + }} + onPointerMove={(e) => active && move(e)} + onPointerUp={stop} + onPointerCancel={stop} + title={enabled ? "Drag to drive" : "No drivable bot selected"} + style={{ + position: "relative", + width: PAD, + height: PAD, + borderRadius: "50%", + background: "var(--elevated)", + border: `1px solid ${active ? "var(--accent)" : "var(--hairline)"}`, + opacity: enabled ? 1 : 0.4, + cursor: enabled ? "grab" : "not-allowed", + touchAction: "none", + flex: "none", + }} + > +
+
+
+
+ ); +}; diff --git a/dotbot/console/src/MapView.tsx b/dotbot/console/src/MapView.tsx new file mode 100644 index 00000000..80204af6 --- /dev/null +++ b/dotbot/console/src/MapView.tsx @@ -0,0 +1,505 @@ +import React, { useCallback, useMemo, useRef, useState } from "react"; + +import { LH2Position, MapSize, UnifiedBot } from "./types"; + +export interface Layers { + batteryBars: boolean; + waypoints: boolean; + trails: boolean; + labels: boolean; +} + +interface MapViewProps { + bots: UnifiedBot[]; + mapSize: MapSize; + selection: Set; + layers: Layers; + pendingWaypoints: LH2Position[]; // local queue, not yet sent + onSelect: (ids: string[], additive: boolean) => void; + onAddWaypoint: (p: LH2Position) => void; +} + +const BOT_R = 11; // px radius of the bot circle at zoom 1 + +interface Camera { + scale: number; + tx: number; + ty: number; +} + +export const MapView: React.FC = (props) => { + const wrapRef = useRef(null); + const [cam, setCam] = useState({ scale: 1, tx: 0, ty: 0 }); + const [marquee, setMarquee] = useState<{ + x0: number; + y0: number; + x1: number; + y1: number; + } | null>(null); + const dragRef = useRef<{ additive: boolean } | null>(null); + + // The arena is drawn as a square of `side` px centered in the wrapper. + const [side, setSide] = useState(600); + const measure = useCallback((el: HTMLDivElement | null) => { + (wrapRef as React.MutableRefObject).current = el; + if (el) { + const r = el.getBoundingClientRect(); + setSide(Math.max(200, Math.min(r.width, r.height) - 48)); + } + }, []); + + const mmToPx = (p: LH2Position) => ({ + left: (p.x / props.mapSize.width) * side, + top: (p.y / props.mapSize.height) * side, + }); + + const pxToMm = (clientX: number, clientY: number): LH2Position | null => { + const el = wrapRef.current; + if (!el) return null; + const r = el.getBoundingClientRect(); + // Undo camera transform (transform origin is the wrapper center). + const cx = r.left + r.width / 2; + const cy = r.top + r.height / 2; + const ux = (clientX - cx - cam.tx) / cam.scale + r.width / 2; + const uy = (clientY - cy - cam.ty) / cam.scale + r.height / 2; + // Arena square is centered in the wrapper. + const ax = ux - (r.width - side) / 2; + const ay = uy - (r.height - side) / 2; + const x = (ax / side) * props.mapSize.width; + const y = (ay / side) * props.mapSize.height; + if (x < 0 || y < 0 || x > props.mapSize.width || y > props.mapSize.height) + return null; + return { x: Math.round(x), y: Math.round(y) }; + }; + + const onCanvasDown = (e: React.PointerEvent) => { + if (e.button !== 0) return; + if (e.altKey) { + const p = pxToMm(e.clientX, e.clientY); + if (p) props.onAddWaypoint(p); + return; + } + // Start a marquee from empty canvas. + dragRef.current = { additive: e.shiftKey }; + setMarquee({ x0: e.clientX, y0: e.clientY, x1: e.clientX, y1: e.clientY }); + (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); + }; + + const onCanvasMove = (e: React.PointerEvent) => { + if (!dragRef.current) return; + setMarquee((m) => (m ? { ...m, x1: e.clientX, y1: e.clientY } : m)); + }; + + const onCanvasUp = () => { + if (!dragRef.current || !marquee) { + dragRef.current = null; + setMarquee(null); + return; + } + const { additive } = dragRef.current; + const x0 = Math.min(marquee.x0, marquee.x1); + const x1 = Math.max(marquee.x0, marquee.x1); + const y0 = Math.min(marquee.y0, marquee.y1); + const y1 = Math.max(marquee.y0, marquee.y1); + const isClick = x1 - x0 < 5 && y1 - y0 < 5; + if (isClick) { + props.onSelect([], additive); // click on empty canvas clears + } else { + const hits = props.bots + .filter((b) => { + if (!b.position) return false; + const el = document.getElementById(`bot-${b.id}`); + if (!el) return false; + const r = el.getBoundingClientRect(); + const cx = r.left + r.width / 2; + const cy = r.top + r.height / 2; + return cx >= x0 && cx <= x1 && cy >= y0 && cy <= y1; + }) + .map((b) => b.id); + props.onSelect(hits, additive); + } + dragRef.current = null; + setMarquee(null); + }; + + const gridStep = side / 10; + const gridBg = useMemo( + () => + `repeating-linear-gradient(0deg, var(--grid) 0 1px, transparent 1px ${gridStep}px),` + + `repeating-linear-gradient(90deg, var(--grid) 0 1px, transparent 1px ${gridStep}px)`, + [gridStep], + ); + + return ( +
+ {/* camera layer */} +
+ {/* arena */} +
+ {/* trails */} + {props.layers.trails && ( + + {props.bots + .filter((b) => b.trail.length > 1) + .map((b) => ( + { + const q = mmToPx(p); + return `${q.left},${q.top}`; + }) + .join(" ")} + fill="none" + stroke={ + b.led + ? `rgb(${b.led.red},${b.led.green},${b.led.blue})` + : "var(--muted)" + } + strokeWidth={1} + opacity={0.35} + /> + ))} + + )} + + {/* active missions: waypoints already sent to bots */} + {props.layers.waypoints && + props.bots + .filter((b) => b.waypoints.length > 0 && b.position) + .map((b) => ( + + { + const q = mmToPx(p); + return `${q.left},${q.top}`; + }) + .join(" ")} + fill="none" + stroke="var(--s-Programming)" + strokeWidth={1.5} + strokeDasharray="5 4" + opacity={0.8} + /> + {b.waypoints.map((p, i) => { + const q = mmToPx(p); + return ( + + ); + })} + + ))} + + {/* pending (queued, not sent) waypoints */} + {props.layers.waypoints && + props.pendingWaypoints.map((p, i) => { + const q = mmToPx(p); + return ( +
+ ); + })} + + {/* bots */} + {props.bots + .filter((b) => b.position) + .map((b) => { + const q = mmToPx(b.position!); + const selected = props.selection.has(b.id); + const ledColor = b.led + ? `rgb(${b.led.red},${b.led.green},${b.led.blue})` + : "var(--s-Inactive)"; + const batPct = Math.max( + 0, + Math.min(100, ((b.battery - 2.0) / (4.2 - 2.0)) * 100), + ); + return ( +
{ + e.stopPropagation(); + props.onSelect([b.id], e.shiftKey); + }} + style={{ + position: "absolute", + left: q.left, + top: q.top, + width: 0, + height: 0, + cursor: "pointer", + }} + title={`${b.id} - ${b.state}${b.drivable ? "" : " (not drivable)"}`} + > + {/* selection rectangle */} + {selected && ( +
+ )} + {/* battery bar */} + {props.layers.batteryBars && ( +
+
+
+ )} + {/* body: LED-colored circle */} +
+ {/* heading pointer */} + {b.heading !== null && ( +
+
+
+ )} + {/* state pip */} +
+ {/* label */} + {props.layers.labels && ( +
+ {b.id.slice(-4)} +
+ )} +
+ ); + })} +
+
+ + {/* marquee */} + {marquee && ( +
+ )} + + {/* zoom controls */} +
e.stopPropagation()} + > + {[ + { + label: "+", + fn: () => setCam((c) => ({ ...c, scale: Math.min(4, c.scale * 1.25) })), + }, + { + label: "−", + fn: () => setCam((c) => ({ ...c, scale: Math.max(0.5, c.scale / 1.25) })), + }, + { label: "◎", fn: () => setCam({ scale: 1, tx: 0, ty: 0 }) }, + ].map((z, i) => ( +
+ {z.label} +
+ ))} +
+ + {/* hint */} +
+ drag = select · shift = add · ⌥ alt-click = waypoint +
+
+ ); +}; diff --git a/dotbot/console/src/api.ts b/dotbot/console/src/api.ts new file mode 100644 index 00000000..34ced4f0 --- /dev/null +++ b/dotbot/console/src/api.ts @@ -0,0 +1,64 @@ +import { LH2Position, MapSize, PyDotBot, RgbLed, SwarmitNode } from "./types"; + +// Same-origin in dev thanks to the vite proxy (see vite.config.ts). +const CONTROLLER = "/controller"; +const SWARMIT = "/swarmit"; + +export async function fetchDotBots(): Promise { + const res = await fetch(`${CONTROLLER}/dotbots`); + return res.json(); +} + +export async function fetchMapSize(): Promise { + const res = await fetch(`${CONTROLLER}/map_size`); + return res.json(); +} + +export async function fetchSwarmitStatus(): Promise> { + const res = await fetch(`${SWARMIT}/status`); + const body = await res.json(); + return body.response ?? {}; +} + +export async function putMoveRaw( + address: string, + application: number, + left: number, + right: number, +): Promise { + await fetch(`${CONTROLLER}/dotbots/${address}/${application}/move_raw`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ left_x: 0, left_y: left, right_x: 0, right_y: right }), + }); +} + +export async function putRgbLed( + address: string, + application: number, + led: RgbLed, +): Promise { + await fetch(`${CONTROLLER}/dotbots/${address}/${application}/rgb_led`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(led), + }); +} + +export async function putWaypoints( + address: string, + application: number, + threshold: number, + waypoints: LH2Position[], +): Promise { + await fetch(`${CONTROLLER}/dotbots/${address}/${application}/waypoints`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ threshold, waypoints }), + }); +} + +export function controllerWsUrl(): string { + const proto = window.location.protocol === "https:" ? "wss" : "ws"; + return `${proto}://${window.location.host}${CONTROLLER}/ws/status`; +} diff --git a/dotbot/console/src/main.tsx b/dotbot/console/src/main.tsx new file mode 100644 index 00000000..e64b87be --- /dev/null +++ b/dotbot/console/src/main.tsx @@ -0,0 +1,11 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; + +import { App } from "./App"; +import "./tokens.css"; + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/dotbot/console/src/tokens.css b/dotbot/console/src/tokens.css new file mode 100644 index 00000000..679a0b4c --- /dev/null +++ b/dotbot/console/src/tokens.css @@ -0,0 +1,54 @@ +/* Design tokens - the single source of truth for every color in the console. + Swap values here to re-theme; components must reference tokens only. */ +:root { + --canvas: #14161b; + --surface: #1e222b; + --elevated: #2a2f3a; + --grid: #2c313c; + --text: #e6e9ef; + --muted: #9aa4b2; + --accent: #e4032e; + --hairline: rgba(255, 255, 255, 0.07); + + /* State colors are shared across themes: a color always means the same thing. */ + --s-Running: #22c55e; + --s-Programming: #f59e0b; + --s-Bootloader: #38bdf8; + --s-Stopping: #ef4444; + --s-Resetting: #a855f7; + --s-Inactive: #6b7280; + + --font-ui: "IBM Plex Sans", system-ui, sans-serif; + --font-mono: "IBM Plex Mono", ui-monospace, monospace; +} + +[data-theme="light"] { + --canvas: #f4f6fa; + --surface: #ffffff; + --elevated: #eef1f6; + --grid: #dbe1ea; + --text: #0f172a; + --muted: #5b6577; + --hairline: rgba(0, 0, 0, 0.07); +} + +* { + box-sizing: border-box; +} +body { + margin: 0; +} +a { + color: var(--accent); + text-decoration: none; +} + +@keyframes dbBlink { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.35; + } +} diff --git a/dotbot/console/src/types.ts b/dotbot/console/src/types.ts new file mode 100644 index 00000000..4b3f05df --- /dev/null +++ b/dotbot/console/src/types.ts @@ -0,0 +1,79 @@ +// SwarmIT node lifecycle states, plus UI-derived Inactive. +export type BotState = + | "Running" + | "Programming" + | "Bootloader" + | "Stopping" + | "Resetting" + | "Inactive"; + +export const STATE_ORDER: BotState[] = [ + "Running", + "Programming", + "Bootloader", + "Stopping", + "Resetting", + "Inactive", +]; + +// PyDotBot REST/WS shapes (subset the console consumes). +export interface LH2Position { + x: number; + y: number; +} + +export interface RgbLed { + red: number; + green: number; + blue: number; +} + +export interface PyDotBot { + address: string; + application: number; // ApplicationType: 0 = DotBot + status: number; // 0 ACTIVE, 1 INACTIVE, 2 LOST + direction?: number; + lh2_position?: LH2Position; + position_history?: LH2Position[]; + waypoints?: LH2Position[]; + waypoints_threshold?: number; + rgb_led?: RgbLed; + battery?: number; // volts + calibrated?: number; +} + +export interface WsNotification { + cmd: number; // 1 RELOAD, 2 UPDATE, 4 NEW_DOTBOT + data?: Partial & { + lh2_waypoints?: LH2Position[]; + }; +} + +// SwarmIT /status record. +export interface SwarmitNode { + device: string; + status: string; // Bootloader | Running | Stopping | Resetting | Programming + battery: number; // millivolts + pos_x: number; + pos_y: number; +} + +// The merged per-bot object the UI binds to (see plans/unified-web-ui/reference.html). +export interface UnifiedBot { + id: string; // hex address, the join key + state: BotState; + position: LH2Position | null; // arena mm + heading: number | null; // degrees + battery: number; // volts + led: RgbLed | null; + deviceType: string; + application: number; + drivable: boolean; // a DBP-speaking image is running (= known to PyDotBot and active) + waypoints: LH2Position[]; // active mission (as reported by the controller) + trail: LH2Position[]; +} + +export interface MapSize { + width: number; + height: number; +} diff --git a/dotbot/console/src/useFleet.ts b/dotbot/console/src/useFleet.ts new file mode 100644 index 00000000..21b55b99 --- /dev/null +++ b/dotbot/console/src/useFleet.ts @@ -0,0 +1,173 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +import { controllerWsUrl, fetchDotBots, fetchMapSize, fetchSwarmitStatus } from "./api"; +import { + BotState, + MapSize, + PyDotBot, + SwarmitNode, + UnifiedBot, + WsNotification, +} from "./types"; + +const TRAIL_MAX = 200; + +function deriveState( + py: PyDotBot | undefined, + sw: SwarmitNode | undefined, +): BotState { + if (py && py.status !== 0) return "Inactive"; // control plane says INACTIVE/LOST + if (sw) { + const s = sw.status as BotState; + return ( + ["Running", "Programming", "Bootloader", "Stopping", "Resetting"] as BotState[] + ).includes(s) + ? s + : "Inactive"; + } + if (py) return "Running"; // control plane only (bare-mode bot): active = running + return "Inactive"; +} + +function merge( + pyBots: Record, + swNodes: Record, +): UnifiedBot[] { + const ids = new Set([...Object.keys(pyBots), ...Object.keys(swNodes)]); + const out: UnifiedBot[] = []; + for (const id of ids) { + const py = pyBots[id]; + const sw = swNodes[id]; + const state = deriveState(py, sw); + out.push({ + id, + state, + position: + py?.lh2_position ?? (sw ? { x: sw.pos_x, y: sw.pos_y } : null), + heading: + py?.direction !== undefined && py.direction !== -1000 + ? py.direction + : null, + battery: py?.battery ?? (sw ? sw.battery / 1000 : 0), + led: py?.rgb_led ?? null, + deviceType: sw?.device ?? "DotBot", + application: py?.application ?? 0, + // Drivable = a DBP-speaking image is running: the bot is known to the + // control plane and active. SwarmIT-only bots (e.g. sitting in the + // bootloader) are not drivable. + drivable: !!py && py.status === 0 && state === "Running", + waypoints: py?.waypoints ?? [], + trail: py?.position_history?.slice(-TRAIL_MAX) ?? [], + }); + } + return out.sort((a, b) => a.id.localeCompare(b.id)); +} + +export function useFleet(): { + bots: UnifiedBot[]; + mapSize: MapSize; + wsUp: boolean; +} { + const pyRef = useRef>({}); + const swRef = useRef>({}); + const [bots, setBots] = useState([]); + const [mapSize, setMapSize] = useState({ width: 2000, height: 2000 }); + const [wsUp, setWsUp] = useState(false); + + const rebuild = useCallback(() => { + setBots(merge(pyRef.current, swRef.current)); + }, []); + + const reloadDotBots = useCallback(async () => { + try { + const list = await fetchDotBots(); + pyRef.current = Object.fromEntries(list.map((b) => [b.address, b])); + rebuild(); + } catch { + /* controller not up yet; ws reconnect loop will retrigger */ + } + }, [rebuild]); + + // Initial data + map size. + useEffect(() => { + reloadDotBots(); + fetchMapSize() + .then(setMapSize) + .catch(() => {}); + }, [reloadDotBots]); + + // Live updates over the controller WebSocket. + useEffect(() => { + let ws: WebSocket | null = null; + let closed = false; + const connect = () => { + ws = new WebSocket(controllerWsUrl()); + ws.onopen = () => { + setWsUp(true); + reloadDotBots(); + }; + ws.onclose = () => { + setWsUp(false); + if (!closed) setTimeout(connect, 1000); + }; + ws.onerror = () => ws?.close(); + ws.onmessage = (ev) => { + let msg: WsNotification; + try { + msg = JSON.parse(ev.data); + } catch { + return; + } + if (msg.cmd === 2 && msg.data?.address) { + const bot = pyRef.current[msg.data.address]; + if (!bot) { + reloadDotBots(); + return; + } + const d = msg.data; + if (d.direction !== undefined) bot.direction = d.direction; + if (d.battery !== undefined) bot.battery = d.battery; + if (d.rgb_led !== undefined) bot.rgb_led = d.rgb_led; + if (d.lh2_position !== undefined) { + bot.lh2_position = d.lh2_position; + bot.position_history = [ + ...(bot.position_history ?? []), + d.lh2_position!, + ].slice(-TRAIL_MAX); + } + if (d.position_history !== undefined) + bot.position_history = d.position_history; + if (d.lh2_waypoints !== undefined) bot.waypoints = d.lh2_waypoints; + if (d.waypoints_threshold !== undefined) + bot.waypoints_threshold = d.waypoints_threshold; + rebuild(); + } else { + // RELOAD / NEW_DOTBOT / unknown -> refetch everything. + reloadDotBots(); + } + }; + }; + connect(); + return () => { + closed = true; + ws?.close(); + }; + }, [reloadDotBots, rebuild]); + + // SwarmIT status poll (read-only orchestration plane), 1 Hz. + useEffect(() => { + const tick = async () => { + try { + swRef.current = await fetchSwarmitStatus(); + } catch { + swRef.current = {}; + } + rebuild(); + }; + tick(); + const t = setInterval(tick, 1000); + return () => clearInterval(t); + }, [rebuild]); + + return { bots, mapSize, wsUp }; +} diff --git a/dotbot/console/tsconfig.json b/dotbot/console/tsconfig.json new file mode 100644 index 00000000..6bfa73af --- /dev/null +++ b/dotbot/console/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/dotbot/console/vite.config.ts b/dotbot/console/vite.config.ts new file mode 100644 index 00000000..0f99db96 --- /dev/null +++ b/dotbot/console/vite.config.ts @@ -0,0 +1,28 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +// Dev proxies: the console talks same-origin, vite forwards to the two backends. +// /controller -> PyDotBot controller (REST + WS), default :8000 +// /swarmit -> swarmit status server (real or fake), default :8001 +// Override with CONTROLLER_TARGET / SWARMIT_TARGET env vars when the default +// ports are occupied by another controller/swarmit instance. +const controllerTarget = + process.env.CONTROLLER_TARGET ?? "http://localhost:8000"; +const swarmitTarget = process.env.SWARMIT_TARGET ?? "http://localhost:8001"; + +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + proxy: { + "/controller": { + target: controllerTarget, + ws: true, + }, + "/swarmit": { + target: swarmitTarget, + rewrite: (path) => path.replace(/^\/swarmit/, ""), + }, + }, + }, +}); From c4bf1cd96ec9c984759edfe34ff5599e88693307 Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Thu, 9 Jul 2026 13:21:09 +0200 Subject: [PATCH 02/27] dotbot/console: add dev harness (fake swarmit server + demo fleet) AI-assisted: Claude Fable 5 --- dotbot/console/dev/fake_swarmit_server.py | 104 +++++++++++++++++++ dotbot/console/dev/simulator_init_state.toml | 47 +++++++++ 2 files changed, 151 insertions(+) create mode 100644 dotbot/console/dev/fake_swarmit_server.py create mode 100644 dotbot/console/dev/simulator_init_state.toml diff --git a/dotbot/console/dev/fake_swarmit_server.py b/dotbot/console/dev/fake_swarmit_server.py new file mode 100644 index 00000000..6ba05f05 --- /dev/null +++ b/dotbot/console/dev/fake_swarmit_server.py @@ -0,0 +1,104 @@ +"""Fake SwarmIT status server for console development. + +Serves the two read-only endpoints the console consumes, shaped exactly like +swarmit's webserver: + +- GET /status -> {"response": {"": {device, status, battery, pos_x, pos_y}}} +- GET /settings -> {network_id, area_width, area_height, calibration_distance, auth_mode} + +Bot addresses are read live from the PyDotBot controller (GET /controller/dotbots) +so the two planes join on the same ids. States are simulated: everything Running, +except a periodic Programming episode that sweeps the fleet, plus two ghost bots +that exist only on the orchestration plane (state Bootloader, not drivable) to +exercise the console's conditional-controllability UI. + +Usage: python fake_swarmit_server.py [--controller http://localhost:8000] [--port 8001] +""" + +import argparse +import json +import time +import urllib.request + +import uvicorn +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +GHOSTS = { + "C0FFEE0000000001": {"pos_x": 300, "pos_y": 1800}, + "C0FFEE0000000002": {"pos_x": 1750, "pos_y": 1650}, +} + +PROGRAMMING_PERIOD_S = 25 # a bot enters Programming this often +PROGRAMMING_LASTS_S = 8 + +app = FastAPI(title="fake-swarmit") +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], +) + +settings = {"controller": "http://localhost:8000"} + + +def fetch_dotbots() -> list: + try: + with urllib.request.urlopen( + f"{settings['controller']}/controller/dotbots", timeout=2 + ) as res: + return json.loads(res.read()) + except Exception: + return [] + + +@app.get("/status") +def status(): + now = time.time() + response = {} + bots = fetch_dotbots() + for i, bot in enumerate(sorted(bots, key=lambda b: b["address"])): + addr = bot["address"] + state = "Running" + # Deterministic Programming sweep: bot i is Programming during its slot. + slot = int(now // PROGRAMMING_PERIOD_S) % max(len(bots), 1) + if i == slot and (now % PROGRAMMING_PERIOD_S) < PROGRAMMING_LASTS_S: + state = "Programming" + pos = bot.get("lh2_position") or {} + response[addr] = { + "device": "DotBotV3", + "status": state, + "battery": int(float(bot.get("battery", 3.0)) * 1000), + "pos_x": int(pos.get("x", 0)), + "pos_y": int(pos.get("y", 0)), + } + for addr, pos in GHOSTS.items(): + response[addr] = { + "device": "DotBotV3", + "status": "Bootloader", + "battery": 3900, + "pos_x": pos["pos_x"], + "pos_y": pos["pos_y"], + } + return {"response": response} + + +@app.get("/settings") +def get_settings(): + return { + "network_id": 0x12, + "area_width": 2000, + "area_height": 2000, + "calibration_distance": 400, + "auth_mode": "none", + } + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--controller", default="http://localhost:8000") + parser.add_argument("--port", type=int, default=8001) + args = parser.parse_args() + settings["controller"] = args.controller + uvicorn.run(app, host="127.0.0.1", port=args.port, log_level="warning") diff --git a/dotbot/console/dev/simulator_init_state.toml b/dotbot/console/dev/simulator_init_state.toml new file mode 100644 index 00000000..8515ba73 --- /dev/null +++ b/dotbot/console/dev/simulator_init_state.toml @@ -0,0 +1,47 @@ +# Demo fleet for console development. Run the simulator from this directory +# (it picks up ./simulator_init_state.toml) or copy this file next to it. + +[network] +pdr = 100 + +[[dotbots]] +address = "BADCAFE111111111" +calibrated = 0xff +pos_x = 400 +pos_y = 350 +direction = 45 + +[[dotbots]] +address = "DEADBEEF22222222" +calibrated = 0xff +pos_x = 1500 +pos_y = 250 +direction = 180 + +[[dotbots]] +address = "B0B0F00D33333333" +calibrated = 0xff +pos_x = 1500 +pos_y = 1500 +direction = 270 + +[[dotbots]] +address = "CAFEBABE44444444" +calibrated = 0xff +pos_x = 600 +pos_y = 1200 +direction = 90 + +[[dotbots]] +address = "FACEFEED55555555" +calibrated = 0xff +pos_x = 1000 +pos_y = 800 +direction = 0 + +[[dotbots]] +address = "ABAD1DEA66666666" +calibrated = 0xff +pos_x = 250 +pos_y = 900 +direction = 135 From effbd47ba94f2530f141b685fb59142af5fbfdb2 Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Thu, 9 Jul 2026 14:19:10 +0200 Subject: [PATCH 03/27] dotbot/console: rename to console-web (a console could also be a TUI) AI-assisted: Claude Fable 5 --- dotbot/{console => console-web}/dev/fake_swarmit_server.py | 0 dotbot/{console => console-web}/dev/simulator_init_state.toml | 0 dotbot/{console => console-web}/index.html | 0 dotbot/{console => console-web}/package-lock.json | 4 ++-- dotbot/{console => console-web}/package.json | 2 +- dotbot/{console => console-web}/src/App.tsx | 0 dotbot/{console => console-web}/src/Footer.tsx | 0 dotbot/{console => console-web}/src/Joystick.tsx | 0 dotbot/{console => console-web}/src/MapView.tsx | 0 dotbot/{console => console-web}/src/api.ts | 0 dotbot/{console => console-web}/src/main.tsx | 0 dotbot/{console => console-web}/src/tokens.css | 0 dotbot/{console => console-web}/src/types.ts | 0 dotbot/{console => console-web}/src/useFleet.ts | 0 dotbot/{console => console-web}/tsconfig.json | 0 dotbot/{console => console-web}/vite.config.ts | 0 16 files changed, 3 insertions(+), 3 deletions(-) rename dotbot/{console => console-web}/dev/fake_swarmit_server.py (100%) rename dotbot/{console => console-web}/dev/simulator_init_state.toml (100%) rename dotbot/{console => console-web}/index.html (100%) rename dotbot/{console => console-web}/package-lock.json (99%) rename dotbot/{console => console-web}/package.json (93%) rename dotbot/{console => console-web}/src/App.tsx (100%) rename dotbot/{console => console-web}/src/Footer.tsx (100%) rename dotbot/{console => console-web}/src/Joystick.tsx (100%) rename dotbot/{console => console-web}/src/MapView.tsx (100%) rename dotbot/{console => console-web}/src/api.ts (100%) rename dotbot/{console => console-web}/src/main.tsx (100%) rename dotbot/{console => console-web}/src/tokens.css (100%) rename dotbot/{console => console-web}/src/types.ts (100%) rename dotbot/{console => console-web}/src/useFleet.ts (100%) rename dotbot/{console => console-web}/tsconfig.json (100%) rename dotbot/{console => console-web}/vite.config.ts (100%) diff --git a/dotbot/console/dev/fake_swarmit_server.py b/dotbot/console-web/dev/fake_swarmit_server.py similarity index 100% rename from dotbot/console/dev/fake_swarmit_server.py rename to dotbot/console-web/dev/fake_swarmit_server.py diff --git a/dotbot/console/dev/simulator_init_state.toml b/dotbot/console-web/dev/simulator_init_state.toml similarity index 100% rename from dotbot/console/dev/simulator_init_state.toml rename to dotbot/console-web/dev/simulator_init_state.toml diff --git a/dotbot/console/index.html b/dotbot/console-web/index.html similarity index 100% rename from dotbot/console/index.html rename to dotbot/console-web/index.html diff --git a/dotbot/console/package-lock.json b/dotbot/console-web/package-lock.json similarity index 99% rename from dotbot/console/package-lock.json rename to dotbot/console-web/package-lock.json index 275a2ac0..c72837fe 100644 --- a/dotbot/console/package-lock.json +++ b/dotbot/console-web/package-lock.json @@ -1,11 +1,11 @@ { - "name": "dotbot-console", + "name": "dotbot-console-web", "version": "0.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "dotbot-console", + "name": "dotbot-console-web", "version": "0.0.1", "dependencies": { "react": "^18.2.0", diff --git a/dotbot/console/package.json b/dotbot/console-web/package.json similarity index 93% rename from dotbot/console/package.json rename to dotbot/console-web/package.json index 927dfd9e..ad0cb6c6 100644 --- a/dotbot/console/package.json +++ b/dotbot/console-web/package.json @@ -1,5 +1,5 @@ { - "name": "dotbot-console", + "name": "dotbot-console-web", "private": true, "version": "0.0.1", "type": "module", diff --git a/dotbot/console/src/App.tsx b/dotbot/console-web/src/App.tsx similarity index 100% rename from dotbot/console/src/App.tsx rename to dotbot/console-web/src/App.tsx diff --git a/dotbot/console/src/Footer.tsx b/dotbot/console-web/src/Footer.tsx similarity index 100% rename from dotbot/console/src/Footer.tsx rename to dotbot/console-web/src/Footer.tsx diff --git a/dotbot/console/src/Joystick.tsx b/dotbot/console-web/src/Joystick.tsx similarity index 100% rename from dotbot/console/src/Joystick.tsx rename to dotbot/console-web/src/Joystick.tsx diff --git a/dotbot/console/src/MapView.tsx b/dotbot/console-web/src/MapView.tsx similarity index 100% rename from dotbot/console/src/MapView.tsx rename to dotbot/console-web/src/MapView.tsx diff --git a/dotbot/console/src/api.ts b/dotbot/console-web/src/api.ts similarity index 100% rename from dotbot/console/src/api.ts rename to dotbot/console-web/src/api.ts diff --git a/dotbot/console/src/main.tsx b/dotbot/console-web/src/main.tsx similarity index 100% rename from dotbot/console/src/main.tsx rename to dotbot/console-web/src/main.tsx diff --git a/dotbot/console/src/tokens.css b/dotbot/console-web/src/tokens.css similarity index 100% rename from dotbot/console/src/tokens.css rename to dotbot/console-web/src/tokens.css diff --git a/dotbot/console/src/types.ts b/dotbot/console-web/src/types.ts similarity index 100% rename from dotbot/console/src/types.ts rename to dotbot/console-web/src/types.ts diff --git a/dotbot/console/src/useFleet.ts b/dotbot/console-web/src/useFleet.ts similarity index 100% rename from dotbot/console/src/useFleet.ts rename to dotbot/console-web/src/useFleet.ts diff --git a/dotbot/console/tsconfig.json b/dotbot/console-web/tsconfig.json similarity index 100% rename from dotbot/console/tsconfig.json rename to dotbot/console-web/tsconfig.json diff --git a/dotbot/console/vite.config.ts b/dotbot/console-web/vite.config.ts similarity index 100% rename from dotbot/console/vite.config.ts rename to dotbot/console-web/vite.config.ts From bccfc86717a0605c3ad3825a079775a7347c2842 Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Thu, 9 Jul 2026 14:30:07 +0200 Subject: [PATCH 04/27] dotbot/console-web: add list/grid views, minimap pan, toasts AI-assisted: Claude Fable 5 --- dotbot/console-web/src/App.tsx | 141 +++++++++++++----- dotbot/console-web/src/Footer.tsx | 21 ++- dotbot/console-web/src/GridView.tsx | 89 +++++++++++ dotbot/console-web/src/ListView.tsx | 176 ++++++++++++++++++++++ dotbot/console-web/src/MapView.tsx | 29 +++- dotbot/console-web/src/Minimap.tsx | 137 +++++++++++++++++ dotbot/console-web/src/viewChrome.tsx | 203 ++++++++++++++++++++++++++ 7 files changed, 747 insertions(+), 49 deletions(-) create mode 100644 dotbot/console-web/src/GridView.tsx create mode 100644 dotbot/console-web/src/ListView.tsx create mode 100644 dotbot/console-web/src/Minimap.tsx create mode 100644 dotbot/console-web/src/viewChrome.tsx diff --git a/dotbot/console-web/src/App.tsx b/dotbot/console-web/src/App.tsx index c2fe48f2..76c8e694 100644 --- a/dotbot/console-web/src/App.tsx +++ b/dotbot/console-web/src/App.tsx @@ -1,16 +1,35 @@ -import React, { useCallback, useState } from "react"; +import React, { useCallback, useRef, useState } from "react"; import { putWaypoints } from "./api"; import { Footer } from "./Footer"; -import { Layers, MapView } from "./MapView"; +import { GridView } from "./GridView"; +import { ListView } from "./ListView"; +import { Camera, Layers, MapView } from "./MapView"; +import { ViewGeom } from "./Minimap"; import { LH2Position } from "./types"; import { useFleet } from "./useFleet"; const WAYPOINT_THRESHOLD = 60; // mm, arrival radius sent with waypoint missions +type ViewKind = "map" | "list" | "grid"; + export const App: React.FC = () => { const { bots, mapSize, wsUp } = useFleet(); const [theme, setTheme] = useState<"dark" | "light">("dark"); + // ?view=map|list|grid opens a specific view (handy for dev/screenshots). + const [view, setView] = useState(() => { + const v = new URLSearchParams(window.location.search).get("view"); + return v === "list" || v === "grid" ? v : "map"; + }); + const [cam, setCam] = useState({ scale: 1, tx: 0, ty: 0 }); + const [geom, setGeom] = useState(null); + const [toast, setToast] = useState(null); + const toastTimer = useRef(undefined); + const showToast = useCallback((msg: string) => { + setToast(msg); + window.clearTimeout(toastTimer.current); + toastTimer.current = window.setTimeout(() => setToast(null), 2500); + }, []); // ?sel=[,] preselects bots (handy for dev/screenshots). const [selection, setSelection] = useState>(new Set()); const preselRef = React.useRef(false); @@ -61,15 +80,21 @@ export const App: React.FC = () => { drivableSelected.forEach((b) => { putWaypoints(b.id, b.application, WAYPOINT_THRESHOLD, pending).catch(() => {}); }); + showToast( + `${pending.length} waypoint${pending.length > 1 ? "s" : ""} sent to ${drivableSelected.length} bot${ + drivableSelected.length > 1 ? "s" : "" + }`, + ); setPending([]); - }, [pending, drivableSelected]); + }, [pending, drivableSelected, showToast]); const onClearWaypoints = useCallback(() => { setPending([]); drivableSelected.forEach((b) => { putWaypoints(b.id, b.application, WAYPOINT_THRESHOLD, []).catch(() => {}); }); - }, [drivableSelected]); + if (drivableSelected.length > 0) showToast("Mission cleared"); + }, [drivableSelected, showToast]); const layerRows: { key: keyof Layers; label: string }[] = [ { key: "batteryBars", label: "Battery bars" }, @@ -165,37 +190,46 @@ export const App: React.FC = () => {
- {/* Map area with view switcher + layers */} + {/* View area with switcher + layers */}
- + {view === "map" && ( + + )} + {view === "list" && } + {view === "grid" && } - {/* switcher (Map wired; List/Grid land with the next iteration) */} + {/* shared view switcher */}
-
setLayersOpen((v) => !v)} - style={{ - display: "flex", - alignItems: "center", - gap: 6, - padding: "7px 11px", - borderRadius: 8, - cursor: "pointer", - fontSize: 12, - background: layersOpen ? "var(--elevated)" : "var(--surface)", - border: "1px solid var(--hairline)", - boxShadow: "0 4px 16px rgba(0,0,0,.3)", - }} - > - ▤ Layers -
+ {view === "map" && ( +
setLayersOpen((v) => !v)} + style={{ + display: "flex", + alignItems: "center", + gap: 6, + padding: "7px 11px", + borderRadius: 8, + cursor: "pointer", + fontSize: 12, + background: layersOpen ? "var(--elevated)" : "var(--surface)", + border: "1px solid var(--hairline)", + boxShadow: "0 4px 16px rgba(0,0,0,.3)", + }} + > + ▤ Layers +
+ )}
{ boxShadow: "0 4px 16px rgba(0,0,0,.3)", }} > - {["Map", "List", "Grid"].map((v, i) => ( + {(["map", "list", "grid"] as ViewKind[]).map((v) => (
0 ? "Coming in the next iteration" : undefined} + onClick={() => setView(v)} style={{ padding: "6px 14px", borderRadius: 6, fontSize: 12, - cursor: i === 0 ? "default" : "not-allowed", - background: i === 0 ? "var(--accent)" : "transparent", - color: i === 0 ? "#fff" : "var(--muted)", - fontWeight: i === 0 ? 600 : 400, - opacity: i === 0 ? 1 : 0.6, + cursor: "pointer", + background: view === v ? "var(--accent)" : "transparent", + color: view === v ? "#fff" : "var(--muted)", + fontWeight: view === v ? 600 : 400, + textTransform: "capitalize", }} > {v} @@ -229,7 +263,30 @@ export const App: React.FC = () => {
{/* layers panel */} - {layersOpen && ( + {/* toast */} + {toast && ( +
+ {toast} +
+ )} + + {layersOpen && view === "map" && (
{ mapSize={mapSize} selection={selection} pendingWaypoints={pending} + cam={cam} + setCam={setCam} + geom={geom} onSelectState={(ids) => onSelect(ids, false)} onGo={onGo} onClearWaypoints={onClearWaypoints} + onToast={showToast} />
); diff --git a/dotbot/console-web/src/Footer.tsx b/dotbot/console-web/src/Footer.tsx index a8adf5ba..63ab0bd9 100644 --- a/dotbot/console-web/src/Footer.tsx +++ b/dotbot/console-web/src/Footer.tsx @@ -2,6 +2,8 @@ import React, { useState } from "react"; import { putRgbLed } from "./api"; import { Joystick } from "./Joystick"; +import { Camera } from "./MapView"; +import { Minimap, ViewGeom } from "./Minimap"; import { LH2Position, MapSize, STATE_ORDER, UnifiedBot } from "./types"; const SWATCHES: [number, number, number][] = [ @@ -25,9 +27,13 @@ interface FooterProps { mapSize: MapSize; selection: Set; pendingWaypoints: LH2Position[]; + cam: Camera; + setCam: React.Dispatch>; + geom: ViewGeom | null; onSelectState: (ids: string[]) => void; onGo: () => void; onClearWaypoints: () => void; + onToast: (msg: string) => void; } const StateDot: React.FC<{ state: string }> = ({ state }) => ( @@ -68,7 +74,8 @@ const ControlDock: React.FC<{ pending: number; onGo: () => void; onClear: () => void; -}> = ({ targets, pending, onGo, onClear }) => { + onToast: (msg: string) => void; +}> = ({ targets, pending, onGo, onClear, onToast }) => { const [ledOpen, setLedOpen] = useState(false); const drivable = targets.filter((b) => b.drivable); const btn = { @@ -152,6 +159,7 @@ const ControlDock: React.FC<{ drivable.forEach((bot) => putRgbLed(bot.id, bot.application, { red: r, green: g, blue: b }).catch(() => {}), ); + onToast(`LED set on ${drivable.length} bot${drivable.length > 1 ? "s" : ""}`); setLedOpen(false); }} style={{ @@ -192,6 +200,15 @@ export const Footer: React.FC = (props) => { borderTop: "1px solid var(--hairline)", }} > + {/* minimap */} + + {/* fleet strip content */}
{selected.length === 0 && ( @@ -267,6 +284,7 @@ export const Footer: React.FC = (props) => { pending={props.pendingWaypoints.length} onGo={props.onGo} onClear={props.onClearWaypoints} + onToast={props.onToast} />
@@ -303,6 +321,7 @@ export const Footer: React.FC = (props) => { pending={props.pendingWaypoints.length} onGo={props.onGo} onClear={props.onClearWaypoints} + onToast={props.onToast} />
diff --git a/dotbot/console-web/src/GridView.tsx b/dotbot/console-web/src/GridView.tsx new file mode 100644 index 00000000..494cfe43 --- /dev/null +++ b/dotbot/console-web/src/GridView.tsx @@ -0,0 +1,89 @@ +import React from "react"; + +import { UnifiedBot } from "./types"; +import { BatteryCell, FilterBar, LedDot, Pagination, useQueriedBots, useViewQuery } from "./viewChrome"; + +interface GridViewProps { + bots: UnifiedBot[]; + selection: Set; + onSelect: (ids: string[], additive: boolean) => void; +} + +export const GridView: React.FC = ({ bots, selection, onSelect }) => { + const { q, setQ } = useViewQuery(); + const { rows, total, pages } = useQueriedBots(bots, q); + + return ( +
onSelect([], false)} + > + +
+
+ {rows.map((b) => { + const checked = selection.has(b.id); + return ( +
{ + e.stopPropagation(); + onSelect([b.id], e.shiftKey); + }} + style={{ + display: "flex", + flexDirection: "column", + gap: 9, + padding: "13px 14px", + borderRadius: 10, + background: "var(--surface)", + border: checked ? "1px solid var(--accent)" : "1px solid var(--hairline)", + boxShadow: checked ? "0 0 0 1px var(--accent)" : "none", + cursor: "pointer", + }} + > +
+ + {b.id.slice(-4)} +
+ + {b.state} +
+
{b.id}
+ +
+ Device {b.deviceType} +
+
+ ); + })} +
+
+ +
+ ); +}; diff --git a/dotbot/console-web/src/ListView.tsx b/dotbot/console-web/src/ListView.tsx new file mode 100644 index 00000000..370ad3e6 --- /dev/null +++ b/dotbot/console-web/src/ListView.tsx @@ -0,0 +1,176 @@ +import React from "react"; + +import { UnifiedBot } from "./types"; +import { + BatteryCell, + FilterBar, + LedDot, + Pagination, + SortKey, + useQueriedBots, + useViewQuery, +} from "./viewChrome"; + +interface ListViewProps { + bots: UnifiedBot[]; + selection: Set; + onSelect: (ids: string[], additive: boolean) => void; +} + +export const ListView: React.FC = ({ bots, selection, onSelect }) => { + const { q, setQ } = useViewQuery(); + const { rows, total, pages } = useQueriedBots(bots, q); + + const sortBy = (key: SortKey) => + setQ((p) => ({ + ...p, + sortKey: key, + sortDir: p.sortKey === key ? ((p.sortDir * -1) as 1 | -1) : 1, + })); + const arrow = (key: SortKey) => (q.sortKey === key ? (q.sortDir === 1 ? " ↑" : " ↓") : ""); + + const allVisibleSelected = rows.length > 0 && rows.every((b) => selection.has(b.id)); + const toggleAll = () => { + if (allVisibleSelected) { + onSelect(rows.map((b) => b.id), true); // additive toggles them off + } else { + const missing = rows.filter((b) => !selection.has(b.id)).map((b) => b.id); + onSelect(missing, true); + } + }; + + const th: React.CSSProperties = { + padding: "11px 12px", + position: "sticky", + top: 0, + background: "var(--surface)", + borderBottom: "1px solid var(--hairline)", + fontSize: 10, + letterSpacing: ".5px", + textTransform: "uppercase", + color: "var(--muted)", + textAlign: "left", + cursor: "pointer", + userSelect: "none", + }; + const checkBox = (checked: boolean): React.CSSProperties => ({ + width: 15, + height: 15, + borderRadius: 4, + border: "1px solid var(--hairline)", + background: checked ? "var(--accent)" : "transparent", + color: "#fff", + fontSize: 10, + display: "flex", + alignItems: "center", + justifyContent: "center", + cursor: "pointer", + }); + + return ( +
onSelect([], false)} + > + +
e.stopPropagation()} + style={{ + flex: 1, + overflow: "auto", + border: "1px solid var(--hairline)", + borderRadius: 10, + background: "var(--surface)", + }} + > + + + + + + + + + + + + {rows.map((b) => { + const checked = selection.has(b.id); + return ( + onSelect([b.id], false)} + style={{ + cursor: "pointer", + background: checked ? "rgba(228,3,46,.07)" : "transparent", + borderLeft: checked ? "2px solid var(--accent)" : "2px solid transparent", + }} + > + + + + + + + ); + })} + +
+
+ {allVisibleSelected ? "✓" : ""} +
+
sortBy("id")}> + ID{arrow("id")} + sortBy("fw")}> + Device{arrow("fw")} + sortBy("battery")}> + Battery{arrow("battery")} + sortBy("state")}> + State{arrow("state")} +
+
{ + e.stopPropagation(); + onSelect([b.id], true); + }} + style={checkBox(checked)} + > + {checked ? "✓" : ""} +
+
+
+ + {b.id} +
+
+ {b.deviceType} + + + +
+ + {b.state} +
+
+
+ +
+ ); +}; diff --git a/dotbot/console-web/src/MapView.tsx b/dotbot/console-web/src/MapView.tsx index 80204af6..a24a6356 100644 --- a/dotbot/console-web/src/MapView.tsx +++ b/dotbot/console-web/src/MapView.tsx @@ -9,27 +9,30 @@ export interface Layers { labels: boolean; } +export interface Camera { + scale: number; + tx: number; + ty: number; +} + interface MapViewProps { bots: UnifiedBot[]; mapSize: MapSize; selection: Set; layers: Layers; pendingWaypoints: LH2Position[]; // local queue, not yet sent + cam: Camera; + setCam: React.Dispatch>; + onGeom: (g: { w: number; h: number; side: number }) => void; onSelect: (ids: string[], additive: boolean) => void; onAddWaypoint: (p: LH2Position) => void; } const BOT_R = 11; // px radius of the bot circle at zoom 1 -interface Camera { - scale: number; - tx: number; - ty: number; -} - export const MapView: React.FC = (props) => { const wrapRef = useRef(null); - const [cam, setCam] = useState({ scale: 1, tx: 0, ty: 0 }); + const { cam, setCam } = props; const [marquee, setMarquee] = useState<{ x0: number; y0: number; @@ -40,11 +43,15 @@ export const MapView: React.FC = (props) => { // The arena is drawn as a square of `side` px centered in the wrapper. const [side, setSide] = useState(600); + const onGeomRef = useRef(props.onGeom); + onGeomRef.current = props.onGeom; const measure = useCallback((el: HTMLDivElement | null) => { (wrapRef as React.MutableRefObject).current = el; if (el) { const r = el.getBoundingClientRect(); - setSide(Math.max(200, Math.min(r.width, r.height) - 48)); + const s = Math.max(200, Math.min(r.width, r.height) - 48); + setSide(s); + onGeomRef.current({ w: r.width, h: r.height, side: s }); } }, []); @@ -136,6 +143,12 @@ export const MapView: React.FC = (props) => { onPointerDown={onCanvasDown} onPointerMove={onCanvasMove} onPointerUp={onCanvasUp} + onWheel={(e) => + setCam((c) => ({ + ...c, + scale: Math.max(0.5, Math.min(4, c.scale * (e.deltaY < 0 ? 1.1 : 1 / 1.1))), + })) + } style={{ position: "relative", flex: 1, diff --git a/dotbot/console-web/src/Minimap.tsx b/dotbot/console-web/src/Minimap.tsx new file mode 100644 index 00000000..a8f0e00c --- /dev/null +++ b/dotbot/console-web/src/Minimap.tsx @@ -0,0 +1,137 @@ +import React, { useRef } from "react"; + +import { Camera } from "./MapView"; +import { MapSize, UnifiedBot } from "./types"; + +export interface ViewGeom { + w: number; + h: number; + side: number; +} + +interface MinimapProps { + bots: UnifiedBot[]; + mapSize: MapSize; + cam: Camera; + setCam: React.Dispatch>; + geom: ViewGeom | null; +} + +// Whole-arena overview with the current map viewport as a rectangle. +// Dragging moves the camera (the design pans exclusively through here). +export const Minimap: React.FC = ({ bots, mapSize, cam, setCam, geom }) => { + const boxRef = useRef(null); + const dragging = useRef(false); + + // Fraction of the arena visible in the map wrapper, derived from the camera: + // wrapper coords of arena-fraction f: w/2 + (f - 0.5) * side * scale + tx. + const viewportRect = () => { + if (!geom) return null; + const { w, h, side } = geom; + const span = (extent: number, t: number) => { + const min = 0.5 - (extent / 2 + t) / (side * cam.scale); + const max = 0.5 + (extent / 2 - t) / (side * cam.scale); + return [Math.max(0, min), Math.min(1, max)]; + }; + const [x0, x1] = span(w, cam.tx); + const [y0, y1] = span(h, cam.ty); + return { x0, x1, y0, y1 }; + }; + + const centerOn = (clientX: number, clientY: number) => { + const el = boxRef.current; + if (!el || !geom) return; + const r = el.getBoundingClientRect(); + const fx = Math.max(0, Math.min(1, (clientX - r.left) / r.width)); + const fy = Math.max(0, Math.min(1, (clientY - r.top) / r.height)); + setCam((c) => ({ + ...c, + tx: -(fx - 0.5) * geom.side * c.scale, + ty: -(fy - 0.5) * geom.side * c.scale, + })); + }; + + const rect = viewportRect(); + + return ( +
+
+ Arena · {mapSize.width}×{mapSize.height}mm +
+
{ + dragging.current = true; + (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); + centerOn(e.clientX, e.clientY); + }} + onPointerMove={(e) => dragging.current && centerOn(e.clientX, e.clientY)} + onPointerUp={() => (dragging.current = false)} + style={{ + position: "relative", + flex: 1, + background: "var(--canvas)", + border: "1px solid var(--hairline)", + borderRadius: 5, + overflow: "hidden", + cursor: "grab", + touchAction: "none", + }} + > + {bots + .filter((b) => b.position) + .map((b) => ( +
+ ))} + {rect && ( +
+ )} +
+
+ ); +}; diff --git a/dotbot/console-web/src/viewChrome.tsx b/dotbot/console-web/src/viewChrome.tsx new file mode 100644 index 00000000..860332f4 --- /dev/null +++ b/dotbot/console-web/src/viewChrome.tsx @@ -0,0 +1,203 @@ +import React, { useMemo, useState } from "react"; + +import { BotState, STATE_ORDER, UnifiedBot } from "./types"; + +export const PAGE_SIZE = 50; + +export type SortKey = "id" | "fw" | "battery" | "state"; + +export interface ViewQuery { + search: string; + stateFilter: BotState | "all"; + sortKey: SortKey; + sortDir: 1 | -1; + page: number; +} + +export function useViewQuery() { + const [q, setQ] = useState({ + search: "", + stateFilter: "all", + sortKey: "id", + sortDir: 1, + page: 0, + }); + return { q, setQ }; +} + +export function applyQuery(bots: UnifiedBot[], q: ViewQuery): { rows: UnifiedBot[]; total: number; pages: number } { + let rows = bots; + if (q.search) { + const s = q.search.toLowerCase(); + rows = rows.filter((b) => b.id.toLowerCase().includes(s)); + } + if (q.stateFilter !== "all") rows = rows.filter((b) => b.state === q.stateFilter); + const dir = q.sortDir; + rows = [...rows].sort((a, b) => { + switch (q.sortKey) { + case "battery": + return dir * (a.battery - b.battery); + case "state": + return dir * a.state.localeCompare(b.state); + case "fw": + return dir * a.deviceType.localeCompare(b.deviceType); + default: + return dir * a.id.localeCompare(b.id); + } + }); + const total = rows.length; + const pages = Math.max(1, Math.ceil(total / PAGE_SIZE)); + const page = Math.min(q.page, pages - 1); + return { rows: rows.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE), total, pages }; +} + +export const FilterBar: React.FC<{ + q: ViewQuery; + setQ: React.Dispatch>; + total: number; +}> = ({ q, setQ, total }) => ( +
+
+ + setQ((p) => ({ ...p, search: e.target.value, page: 0 }))} + placeholder="Search by ID" + style={{ + background: "transparent", + border: "none", + outline: "none", + color: "var(--text)", + fontFamily: "var(--font-mono)", + fontSize: 12, + width: "100%", + }} + /> +
+ +
+ + {total} bot{total === 1 ? "" : "s"} + +
+); + +export const Pagination: React.FC<{ + q: ViewQuery; + setQ: React.Dispatch>; + pages: number; +}> = ({ q, setQ, pages }) => { + const page = Math.min(q.page, pages - 1); + const btn = (enabled: boolean) => + ({ + cursor: enabled ? "pointer" : "default", + opacity: enabled ? 1 : 0.4, + padding: "4px 8px", + borderRadius: 6, + }) as const; + return ( +
+
page > 0 && setQ((p) => ({ ...p, page: page - 1 }))} style={btn(page > 0)}> + ‹ Prev +
+ + {page + 1} / {pages} + +
page < pages - 1 && setQ((p) => ({ ...p, page: page + 1 }))} style={btn(page < pages - 1)}> + Next › +
+
+ ); +}; + +// Shared cell bits. +export const LedDot: React.FC<{ bot: UnifiedBot }> = ({ bot }) => ( + +); + +export const BatteryCell: React.FC<{ volts: number; width?: number; fill?: boolean }> = ({ + volts, + width = 54, + fill = false, +}) => { + const pct = Math.max(0, Math.min(100, ((volts - 2.0) / (4.2 - 2.0)) * 100)); + return ( +
+
+
+
+ {volts.toFixed(2)} V +
+ ); +}; + +export function useQueriedBots(bots: UnifiedBot[], q: ViewQuery) { + return useMemo(() => applyQuery(bots, q), [bots, q]); +} From 3cf5c0d8eb6f485af3dd560fba20b93586c91d46 Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Thu, 9 Jul 2026 14:49:30 +0200 Subject: [PATCH 05/27] dotbot/console-web: fidelity pass to match design v1 semantics and glyphs Adopts the design's north-up y convention (screen top = arena max-y), which deliberately diverges from dotbot/frontend's y-down map - keep that in mind when comparing positions across the two UIs. AI-assisted: Claude Fable 5 --- dotbot/console-web/src/App.tsx | 417 +++++++++++++--------- dotbot/console-web/src/Footer.tsx | 439 ++++++++++++++++------- dotbot/console-web/src/Joystick.tsx | 129 ++++--- dotbot/console-web/src/MapView.tsx | 534 +++++++++++++++------------- dotbot/console-web/src/Minimap.tsx | 137 ++++--- dotbot/console-web/src/types.ts | 2 + dotbot/console-web/src/useFleet.ts | 1 + 7 files changed, 991 insertions(+), 668 deletions(-) diff --git a/dotbot/console-web/src/App.tsx b/dotbot/console-web/src/App.tsx index 76c8e694..9eb8914e 100644 --- a/dotbot/console-web/src/App.tsx +++ b/dotbot/console-web/src/App.tsx @@ -4,8 +4,7 @@ import { putWaypoints } from "./api"; import { Footer } from "./Footer"; import { GridView } from "./GridView"; import { ListView } from "./ListView"; -import { Camera, Layers, MapView } from "./MapView"; -import { ViewGeom } from "./Minimap"; +import { Camera, Layers, MapView, ViewGeom } from "./MapView"; import { LH2Position } from "./types"; import { useFleet } from "./useFleet"; @@ -13,6 +12,20 @@ const WAYPOINT_THRESHOLD = 60; // mm, arrival radius sent with waypoint missions type ViewKind = "map" | "list" | "grid"; +// Collapsed testbed rail (v1). Orchestration actions arrive with the +// write-path iteration; until then the icons are visible but inert. +const RAIL_ICONS: { glyph: string; title: string }[] = [ + { glyph: "▱", title: "Open testbed (arrives with orchestration)" }, + { glyph: "", title: "" }, // divider + { glyph: "⇩", title: "Flash (arrives with orchestration)" }, + { glyph: "▶", title: "Start (arrives with orchestration)" }, + { glyph: "■", title: "Stop (arrives with orchestration)" }, + { glyph: "↻", title: "Reset (arrives with orchestration)" }, + { glyph: "", title: "" }, // divider + { glyph: "☰", title: "Console (arrives with orchestration)" }, + { glyph: "⋮", title: "Flash queue (arrives with orchestration)" }, +]; + export const App: React.FC = () => { const { bots, mapSize, wsUp } = useFleet(); const [theme, setTheme] = useState<"dark" | "light">("dark"); @@ -30,28 +43,30 @@ export const App: React.FC = () => { window.clearTimeout(toastTimer.current); toastTimer.current = window.setTimeout(() => setToast(null), 2500); }, []); + // ?sel=[,] preselects bots (handy for dev/screenshots). const [selection, setSelection] = useState>(new Set()); - const preselRef = React.useRef(false); + const preselRef = useRef(false); React.useEffect(() => { if (preselRef.current || bots.length === 0) return; const raw = new URLSearchParams(window.location.search).get("sel"); if (raw) { const suffixes = raw.toLowerCase().split(","); - const hits = bots - .filter((b) => suffixes.some((s) => b.id.toLowerCase().endsWith(s))) - .map((b) => b.id); + const hits = bots.filter((b) => suffixes.some((s) => b.id.toLowerCase().endsWith(s))).map((b) => b.id); if (hits.length) setSelection(new Set(hits)); } preselRef.current = true; }, [bots]); + const [pending, setPending] = useState([]); const [layersOpen, setLayersOpen] = useState(false); const [layers, setLayers] = useState({ batteryBars: true, waypoints: true, + hotSpots: false, + dotBots: true, + trueScale: false, trails: false, - labels: true, }); const onSelect = useCallback((ids: string[], additive: boolean) => { @@ -66,6 +81,10 @@ export const App: React.FC = () => { const selectedBots = bots.filter((b) => selection.has(b.id)); const drivableSelected = selectedBots.filter((b) => b.drivable); + const pendingLed = + drivableSelected.length > 0 && drivableSelected[0].led + ? `rgb(${drivableSelected[0].led.red},${drivableSelected[0].led.green},${drivableSelected[0].led.blue})` + : null; const onAddWaypoint = useCallback( (p: LH2Position) => { @@ -88,19 +107,23 @@ export const App: React.FC = () => { setPending([]); }, [pending, drivableSelected, showToast]); - const onClearWaypoints = useCallback(() => { - setPending([]); + const onStopNav = useCallback(() => { drivableSelected.forEach((b) => { putWaypoints(b.id, b.application, WAYPOINT_THRESHOLD, []).catch(() => {}); }); - if (drivableSelected.length > 0) showToast("Mission cleared"); + if (drivableSelected.length > 0) showToast("Navigation stopped"); }, [drivableSelected, showToast]); + const onClearQueue = useCallback(() => setPending([]), []); + const onRemovePending = useCallback((i: number) => setPending((prev) => prev.filter((_, j) => j !== i)), []); + const layerRows: { key: keyof Layers; label: string }[] = [ - { key: "batteryBars", label: "Battery bars" }, + { key: "batteryBars", label: "Battery Bars" }, { key: "waypoints", label: "Waypoints" }, + { key: "hotSpots", label: "HotSpots" }, + { key: "dotBots", label: "DotBots" }, + { key: "trueScale", label: "Real-scale bots" }, { key: "trails", label: "Trails" }, - { key: "labels", label: "Labels" }, ]; return ( @@ -134,9 +157,7 @@ export const App: React.FC = () => { >
DotBots
- - dotbot.local:8000 - + dotbot.local:8000
{ · {bots.length} bots
- {/* theme toggle */} + {/* theme: Dark | Light segmented (v1) */}
setTheme((t) => (t === "dark" ? "light" : "dark"))} - title="Toggle theme" style={{ - position: "relative", - width: 48, - height: 26, - borderRadius: 13, - cursor: "pointer", + display: "flex", background: "var(--elevated)", + borderRadius: 7, + padding: 2, + gap: 2, border: "1px solid var(--hairline)", - flex: "none", }} > - - ☀ - - - ☽ - -
-
-
- - {/* View area with switcher + layers */} -
- {view === "map" && ( - - )} - {view === "list" && } - {view === "grid" && } - - {/* shared view switcher */} -
- {view === "map" && ( + {(["dark", "light"] as const).map((t) => (
setLayersOpen((v) => !v)} + key={t} + onClick={() => setTheme(t)} style={{ - display: "flex", - alignItems: "center", - gap: 6, - padding: "7px 11px", - borderRadius: 8, - cursor: "pointer", + padding: "4px 12px", + borderRadius: 5, fontSize: 12, - background: layersOpen ? "var(--elevated)" : "var(--surface)", - border: "1px solid var(--hairline)", - boxShadow: "0 4px 16px rgba(0,0,0,.3)", + fontWeight: 500, + cursor: "pointer", + background: theme === t ? "var(--accent)" : "transparent", + color: theme === t ? "#fff" : "var(--muted)", + textTransform: "capitalize", }} > - ▤ Layers + {t}
- )} -
- {(["map", "list", "grid"] as ViewKind[]).map((v) => ( + ))} +
+
+ ✕ +
+
+ + {/* Body row: testbed rail + view area */} +
+ {/* collapsed testbed rail (shell; orchestration arrives later) */} +
+ {RAIL_ICONS.map((ic, i) => + ic.glyph === "" ? ( +
+ ) : (
setView(v)} + key={i} + title={ic.title} style={{ - padding: "6px 14px", - borderRadius: 6, - fontSize: 12, - cursor: "pointer", - background: view === v ? "var(--accent)" : "transparent", - color: view === v ? "#fff" : "var(--muted)", - fontWeight: view === v ? 600 : 400, - textTransform: "capitalize", + width: 32, + height: 32, + display: "flex", + alignItems: "center", + justifyContent: "center", + borderRadius: 7, + background: "var(--elevated)", + border: "1px solid var(--hairline)", + cursor: "not-allowed", + fontSize: 13, + color: "var(--muted)", + opacity: 0.6, }} > - {v} + {ic.glyph}
- ))} -
+ ), + )}
- {/* layers panel */} - {/* toast */} - {toast && ( -
- {toast} -
- )} + {/* view area */} +
+ {view === "map" && ( + + )} + {view === "list" && } + {view === "grid" && } - {layersOpen && view === "map" && ( -
- {layerRows.map((l) => ( + {/* shared view switcher */} +
+ {view === "map" && (
setLayers((prev) => ({ ...prev, [l.key]: !prev[l.key] }))} + onClick={() => setLayersOpen((v) => !v)} style={{ display: "flex", alignItems: "center", - justifyContent: "space-between", - padding: "5px 4px", - borderRadius: 5, + gap: 6, + padding: "7px 11px", + borderRadius: 8, cursor: "pointer", fontSize: 12, + background: layersOpen ? "var(--elevated)" : "var(--surface)", + border: "1px solid var(--hairline)", + boxShadow: "0 4px 16px rgba(0,0,0,.3)", }} > - {l.label} - + )} +
+ {(["map", "list", "grid"] as ViewKind[]).map((v) => ( +
setView(v)} style={{ - width: 15, - height: 15, - borderRadius: 4, - border: "1px solid var(--hairline)", - background: layers[l.key] ? "var(--accent)" : "transparent", - color: "#fff", - fontSize: 10, - display: "flex", - alignItems: "center", - justifyContent: "center", + padding: "6px 14px", + borderRadius: 6, + fontSize: 12, + cursor: "pointer", + background: view === v ? "var(--accent)" : "transparent", + color: view === v ? "#fff" : "var(--muted)", + fontWeight: view === v ? 600 : 400, + textTransform: "capitalize", }} > - {layers[l.key] ? "✓" : ""} + {v} +
+ ))} +
+
+ + {/* toast */} + {toast && ( +
+ {toast} +
+ )} + + {/* layers panel */} + {layersOpen && view === "map" && ( +
+
+ Layers +
+ setLayersOpen(false)} style={{ cursor: "pointer", color: "var(--muted)", fontSize: 14, lineHeight: 1 }}> + ✕
- ))} -
- )} + {layerRows.map((l) => ( +
setLayers((prev) => ({ ...prev, [l.key]: !prev[l.key] }))} + style={{ + display: "flex", + alignItems: "center", + justifyContent: "space-between", + padding: "5px 4px", + borderRadius: 5, + cursor: "pointer", + fontSize: 12, + }} + > + {l.label} + + {layers[l.key] ? "✓" : ""} + +
+ ))} +
+ )} +
{ geom={geom} onSelectState={(ids) => onSelect(ids, false)} onGo={onGo} - onClearWaypoints={onClearWaypoints} + onStopNav={onStopNav} + onClearQueue={onClearQueue} + onRemovePending={onRemovePending} onToast={showToast} />
diff --git a/dotbot/console-web/src/Footer.tsx b/dotbot/console-web/src/Footer.tsx index 63ab0bd9..647007a4 100644 --- a/dotbot/console-web/src/Footer.tsx +++ b/dotbot/console-web/src/Footer.tsx @@ -1,11 +1,12 @@ import React, { useState } from "react"; import { putRgbLed } from "./api"; -import { Joystick } from "./Joystick"; -import { Camera } from "./MapView"; -import { Minimap, ViewGeom } from "./Minimap"; +import { Pad } from "./Joystick"; +import { Camera, ViewGeom } from "./MapView"; +import { Minimap } from "./Minimap"; import { LH2Position, MapSize, STATE_ORDER, UnifiedBot } from "./types"; +// v1 swatch palette. const SWATCHES: [number, number, number][] = [ [228, 3, 46], [255, 140, 0], @@ -21,6 +22,15 @@ const SWATCHES: [number, number, number][] = [ const label = { fontSize: 10, letterSpacing: ".5px", textTransform: "uppercase", color: "var(--muted)" } as const; const mono = { fontFamily: "var(--font-mono)" } as const; +// v1 gate style for non-drivable selections. +const gateOff = { opacity: 0.32, pointerEvents: "none" as const, filter: "grayscale(.7)" }; + +const ledCss = (b: UnifiedBot | undefined) => + b?.led ? `rgb(${b.led.red},${b.led.green},${b.led.blue})` : "var(--s-Inactive)"; +const batPct = (v: number) => Math.max(0, Math.min(1, (v - 2.0) / (4.2 - 2.0))); +const batColor = (p: number) => + p < 0.2 ? "var(--s-Stopping)" : p < 0.45 ? "var(--s-Programming)" : "var(--s-Running)"; +const short = (id: string) => id.slice(-4).toUpperCase(); interface FooterProps { bots: UnifiedBot[]; @@ -32,17 +42,20 @@ interface FooterProps { geom: ViewGeom | null; onSelectState: (ids: string[]) => void; onGo: () => void; - onClearWaypoints: () => void; + onStopNav: () => void; + onClearQueue: () => void; + onRemovePending: (index: number) => void; onToast: (msg: string) => void; } -const StateDot: React.FC<{ state: string }> = ({ state }) => ( +const StateDot: React.FC<{ state: string; glow?: boolean; size?: number }> = ({ state, glow, size = 9 }) => ( = ({ state }) => ( ); const BatteryBar: React.FC<{ volts: number }> = ({ volts }) => { - const pct = Math.max(0, Math.min(100, ((volts - 2.0) / (4.2 - 2.0)) * 100)); + const pct = batPct(volts); return (
-
+
{volts.toFixed(2)} V
); }; -// The control dock: joystick + LED + waypoint actions, shared by the -// one-selected and multi-selected panels (same dock, single vs group). +// The control dock, per v1: pad + LED button + segmented waypoint group, +// with popovers anchored above and everything gated when nothing is drivable. const ControlDock: React.FC<{ targets: UnifiedBot[]; - pending: number; + pending: LH2Position[]; + isGroup: boolean; + selCount: number; onGo: () => void; - onClear: () => void; + onStopNav: () => void; + onClearQueue: () => void; + onRemovePending: (i: number) => void; onToast: (msg: string) => void; -}> = ({ targets, pending, onGo, onClear, onToast }) => { +}> = ({ targets, pending, isGroup, selCount, onGo, onStopNav, onClearQueue, onRemovePending, onToast }) => { const [ledOpen, setLedOpen] = useState(false); + const [wpOpen, setWpOpen] = useState(false); const drivable = targets.filter((b) => b.drivable); - const btn = { - display: "flex", - alignItems: "center", - gap: 7, - padding: "6px 12px", - borderRadius: 7, - cursor: "pointer", - background: "var(--elevated)", + const enabled = drivable.length > 0; + const anyAuto = drivable.some((b) => b.nav === "auto"); + const activeCount = drivable.reduce((a, b) => a + b.waypoints.length, 0); + const wpCount = pending.length > 0 ? pending.length : anyAuto ? activeCount : 0; + const single = !isGroup ? targets[0] : undefined; + + const hint = !enabled + ? isGroup + ? "⚠ Not drivable - no DBP in selection" + : `⚠ Not drivable - no DBP in currently running ${single?.state === "Running" ? "image" : single?.state.toLowerCase()}` + : anyAuto + ? `▶ Navigating · ${activeCount} waypoint${activeCount === 1 ? "" : "s"} left` + : `${isGroup ? `${drivable.length} of ${selCount} drivable · ` : "◉ "}Drag pad to drive · ⌥ Alt-click map to add waypoints${pending.length ? ` · ${pending.length} queued` : ""}`; + + const popBase: React.CSSProperties = { + position: "absolute", + bottom: 72, + left: 0, + width: 212, + background: "var(--surface)", border: "1px solid var(--hairline)", - fontSize: 12, - } as const; + borderRadius: 10, + padding: 12, + boxShadow: "0 10px 30px rgba(0,0,0,.45)", + zIndex: 30, + }; + return (
Control
-
- +
+
+ +
-
drivable.length && setLedOpen((v) => !v)} style={{ ...btn, opacity: drivable.length ? 1 : 0.4 }}> + {/* LED button */} +
enabled && setLedOpen((v) => !v)} + style={{ + display: "flex", + alignItems: "center", + gap: 7, + padding: "8px 12px", + borderRadius: 8, + background: "var(--elevated)", + border: "1px solid var(--hairline)", + cursor: "pointer", + fontSize: 12, + ...(enabled ? {} : gateOff), + }} + >
- LED{targets.length > 1 ? " all" : ""} + LED{isGroup ? " all" : ""}
-
+ {/* waypoint group: [Waypoints · N][Go / Stop nav][Clear] */} +
pending > 0 && drivable.length && onGo()} + onClick={() => setWpOpen((v) => !v)} style={{ - ...btn, - background: pending > 0 && drivable.length ? "var(--accent)" : "var(--elevated)", - color: pending > 0 && drivable.length ? "#fff" : "var(--muted)", - fontWeight: 600, + padding: "6px 11px", + cursor: "pointer", + fontSize: 12, + display: "flex", + alignItems: "center", + gap: 6, + whiteSpace: "nowrap", + background: wpOpen ? "rgba(228,3,46,.14)" : "transparent", }} > - ▶ Go{pending > 0 ? ` (${pending})` : ""} -
-
- Clear + ◎ Waypoints{wpCount ? ` · ${wpCount}` : ""}
+ {(pending.length > 0 || anyAuto) && ( +
(anyAuto ? onStopNav() : onGo())} + style={{ + display: "flex", + alignItems: "center", + padding: "6px 11px", + cursor: "pointer", + fontSize: 12, + fontWeight: 600, + whiteSpace: "nowrap", + borderLeft: "1px solid var(--hairline)", + color: anyAuto ? "var(--text)" : "var(--accent)", + }} + > + {anyAuto ? "■ Stop nav" : "▶ Go"} +
+ )} + {pending.length > 0 && ( +
+ Clear +
+ )}
-
- {drivable.length === 0 - ? "Not drivable - no DBP-speaking firmware running." - : `⌥ Alt-click the map to queue waypoints · Go sends · joystick drives ${ - drivable.length > 1 ? `${drivable.length} bots` : "the bot" - }.`} -
- {ledOpen && ( +
{hint}
+ + {/* click-away overlay */} + {(ledOpen || wpOpen) && (
{ + setLedOpen(false); + setWpOpen(false); }} - > -
- LED color{targets.length > 1 ? ` · ${drivable.length} bots` : ""} -
+ /> + )} + + {/* LED popover */} + {ledOpen && ( +
+
LED color{isGroup ? ` · ${drivable.length} bots` : ""}
{SWATCHES.map(([r, g, b], i) => (
))}
)} + + {/* waypoint queue popover */} + {wpOpen && ( +
+
+ Waypoint queue + {pending.length > 0 && ( + + Clear all + + )} +
+ {pending.length > 0 ? ( +
+ {pending.map((w, i) => ( +
+ {i + 1} + + {Math.round(w.x)}, {Math.round(w.y)} mm + + onRemovePending(i)} + style={{ marginLeft: "auto", color: "var(--muted)", cursor: "pointer", fontSize: 14, lineHeight: 1, padding: "0 3px" }} + > + × + +
+ ))} +
+ ) : ( +
+ ⌥ Alt-click the map to add waypoints for {isGroup ? "the selection" : "this bot"}. +
+ )} +
+ )}
); }; @@ -183,11 +313,7 @@ const Sep: React.FC = () =>
= (props) => { const selected = props.bots.filter((b) => props.selection.has(b.id)); - - const rollup = STATE_ORDER.map((s) => ({ - state: s, - ids: props.bots.filter((b) => b.state === s).map((b) => b.id), - })).filter((r) => r.ids.length > 0); + const one = selected.length === 1 ? selected[0] : undefined; return (
= (props) => { borderTop: "1px solid var(--hairline)", }} > - {/* minimap */} - + - {/* fleet strip content */}
+ {/* NONE: fleet rollup (all states, zero-count rows dimmed, per v1) */} {selected.length === 0 && (
@@ -222,92 +341,138 @@ export const Footer: React.FC = (props) => {
- {rollup.map((r) => ( -
props.onSelectState(r.ids)} - title="Select these bots" - style={{ display: "flex", alignItems: "center", gap: 8, cursor: "pointer" }} - > - - {r.ids.length} - {r.state} -
- ))} + {STATE_ORDER.map((s) => { + const ids = props.bots.filter((b) => b.state === s).map((b) => b.id); + return ( +
ids.length && props.onSelectState(ids)} + title={ids.length ? "Select these bots" : undefined} + style={{ + display: "flex", + alignItems: "center", + gap: 9, + opacity: ids.length ? 1 : 0.4, + cursor: ids.length ? "pointer" : "default", + }} + > + + {ids.length} + {s} +
+ ); + })}
)} - {selected.length === 1 && ( + {/* ONE: selected bot */} + {one && (
-
+
- {selected[0].id.slice(-4)} + {short(one.id)}
- {selected[0].id} - {selected[0].deviceType} + {one.id.toUpperCase()} + {one.deviceType} + {"—"} - ◎ {selected[0].drivable ? "drivable" : "not drivable"} + ◎ {one.drivable ? "Drivable" : "Not drivable"}
Status
- - {selected[0].state} + + {one.state}
- + - {selected[0].position ? `${Math.round(selected[0].position.x)}, ${Math.round(selected[0].position.y)}` : "-"} + {one.position ? `${Math.round(one.position.x)}, ${Math.round(one.position.y)} mm` : "— unknown"}
)} + {/* MULTI */} {selected.length > 1 && (
-
- {selected.length} - selected - props.onSelectState([])} style={{ fontSize: 11, color: "var(--accent)", cursor: "pointer" }}> - Clear selection - +
+
+ ☰ +
+
+ {selected.length} + selected + props.onSelectState([])} style={{ fontSize: 11, color: "var(--accent)", cursor: "pointer" }}> + Clear selection + +
-
+
Status
-
+
{STATE_ORDER.map((s) => { const n = selected.filter((b) => b.state === s).length; return n > 0 ? ( -
- +
+ {n} {s}
@@ -318,9 +483,13 @@ export const Footer: React.FC = (props) => {
diff --git a/dotbot/console-web/src/Joystick.tsx b/dotbot/console-web/src/Joystick.tsx index 20c14fd3..216d997e 100644 --- a/dotbot/console-web/src/Joystick.tsx +++ b/dotbot/console-web/src/Joystick.tsx @@ -3,16 +3,16 @@ import React, { useEffect, useRef, useState } from "react"; import { putMoveRaw } from "./api"; import { UnifiedBot } from "./types"; -// Same differential-drive mapping as the classic frontend Joystick: -// dir = -128*y/200, angle = 128*x/200, left = dir+angle, right = dir-angle, -// +/- deadband offset, clamped to [-128, 127]. Published at 10 Hz while held -// (the firmware's ~520 ms deadman stops motors when we stop publishing). +// v1 drive pad: 64px rounded square, crosshair guides, LED-colored knob with +// the bot's live heading pointer (single) or an accent knob with a xN count +// (group). Differential-drive mapping matches the classic frontend: +// dir = -128*y/200, angle = 128*x/200, +/- deadband offset, clamp [-128,127], +// published at 10 Hz while held (firmware deadman stops motors on release). const SPEED_OFFSET = 30; -const PAD = 64; // px, pad size -const R = 22; // px, knob travel radius +const PAD = 64; +const R = 20; // knob travel radius, as in v1 function speeds(dx: number, dy: number): { left: number; right: number } { - // Scale pad offsets to the reference 200 px frame of the original math. const px = (dx / R) * 100; const py = (dy / R) * 100; const dir = (128 * py / 200) * -1; @@ -29,18 +29,25 @@ function speeds(dx: number, dy: number): { left: number; right: number } { }; } -interface JoystickProps { +interface PadProps { targets: UnifiedBot[]; // drivable bots to drive together + disabled: boolean; // parent applies the gate style; this blocks input } -export const Joystick: React.FC = ({ targets }) => { +export const Pad: React.FC = ({ targets, disabled }) => { const [knob, setKnob] = useState({ x: 0, y: 0 }); const [active, setActive] = useState(false); const knobRef = useRef(knob); knobRef.current = knob; const targetsRef = useRef(targets); targetsRef.current = targets; - const enabled = targets.length > 0; + + const single = targets.length === 1 ? targets[0] : null; + const led = single + ? single.led + ? `rgb(${single.led.red},${single.led.green},${single.led.blue})` + : "var(--s-Inactive)" + : null; useEffect(() => { if (!active) return; @@ -54,6 +61,7 @@ export const Joystick: React.FC = ({ targets }) => { }, [active]); const stop = () => { + if (!active) return; setActive(false); setKnob({ x: 0, y: 0 }); targetsRef.current.forEach((b) => { @@ -76,7 +84,7 @@ export const Joystick: React.FC = ({ targets }) => { return (
{ - if (!enabled) return; + if (disabled || targets.length === 0) return; setActive(true); (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); move(e); @@ -84,53 +92,74 @@ export const Joystick: React.FC = ({ targets }) => { onPointerMove={(e) => active && move(e)} onPointerUp={stop} onPointerCancel={stop} - title={enabled ? "Drag to drive" : "No drivable bot selected"} + title="Drag pad to drive" style={{ - position: "relative", width: PAD, height: PAD, - borderRadius: "50%", + flex: "none", + borderRadius: 12, background: "var(--elevated)", border: `1px solid ${active ? "var(--accent)" : "var(--hairline)"}`, - opacity: enabled ? 1 : 0.4, - cursor: enabled ? "grab" : "not-allowed", + position: "relative", touchAction: "none", - flex: "none", + cursor: "grab", }} > -
-
-
+ {/* crosshair guides */} +
+
+ {single ? ( +
+ {single.heading !== null && ( +
+ )} +
+ ) : ( +
+ ×{targets.length} +
+ )}
); }; diff --git a/dotbot/console-web/src/MapView.tsx b/dotbot/console-web/src/MapView.tsx index a24a6356..d709a107 100644 --- a/dotbot/console-web/src/MapView.tsx +++ b/dotbot/console-web/src/MapView.tsx @@ -2,11 +2,15 @@ import React, { useCallback, useMemo, useRef, useState } from "react"; import { LH2Position, MapSize, UnifiedBot } from "./types"; +// Layer set mirrors the v1 design (Battery Bars / Waypoints / HotSpots / +// DotBots / Real-scale bots); Trails is our addition on top. export interface Layers { batteryBars: boolean; waypoints: boolean; + hotSpots: boolean; + dotBots: boolean; + trueScale: boolean; trails: boolean; - labels: boolean; } export interface Camera { @@ -15,33 +19,60 @@ export interface Camera { ty: number; } +export interface ViewGeom { + w: number; + h: number; + side: number; +} + +// v1 clampPan: keep the arena reachable, never fling it off-screen. +export function clampCam(cam: Camera, geom: ViewGeom): Camera { + const sw = geom.side * cam.scale; + const padX = Math.max(0, (geom.w - geom.side) / 2); + const padY = Math.max(0, (geom.h - geom.side) / 2); + const mx = Math.max(0, (sw - geom.w) / 2) + padX; + const my = Math.max(0, (sw - geom.h) / 2) + padY; + return { + ...cam, + tx: Math.max(-mx, Math.min(mx, cam.tx)), + ty: Math.max(-my, Math.min(my, cam.ty)), + }; +} + interface MapViewProps { bots: UnifiedBot[]; mapSize: MapSize; selection: Set; layers: Layers; pendingWaypoints: LH2Position[]; // local queue, not yet sent + pendingLed: string | null; // css color of the queue's owner selection cam: Camera; setCam: React.Dispatch>; - onGeom: (g: { w: number; h: number; side: number }) => void; + onGeom: (g: ViewGeom) => void; onSelect: (ids: string[], additive: boolean) => void; onAddWaypoint: (p: LH2Position) => void; } -const BOT_R = 11; // px radius of the bot circle at zoom 1 +const BOT_R = 11; // px radius of the bot circle at zoom 1 (22px glyph, as in v1) +const REAL_BOT_MM = 80; // approximate DotBot footprint for the Real-scale layer + +const ledCss = (b: UnifiedBot) => + b.led ? `rgb(${b.led.red},${b.led.green},${b.led.blue})` : "var(--s-Inactive)"; + +const batColor = (pct: number) => + pct < 0.2 ? "var(--s-Stopping)" : pct < 0.45 ? "var(--s-Programming)" : "var(--s-Running)"; + +const batPct = (volts: number) => Math.max(0, Math.min(1, (volts - 2.0) / (4.2 - 2.0))); export const MapView: React.FC = (props) => { const wrapRef = useRef(null); const { cam, setCam } = props; - const [marquee, setMarquee] = useState<{ - x0: number; - y0: number; - x1: number; - y1: number; - } | null>(null); - const dragRef = useRef<{ additive: boolean } | null>(null); + const [marquee, setMarquee] = useState<{ x0: number; y0: number; x1: number; y1: number } | null>(null); + const [hoverId, setHoverId] = useState(null); + const panRef = useRef<{ x0: number; y0: number; tx0: number; ty0: number; moved: boolean } | null>(null); + const marqueeRef = useRef<{ additive: boolean } | null>(null); + const geomRef = useRef({ w: 1000, h: 600, side: 600 }); - // The arena is drawn as a square of `side` px centered in the wrapper. const [side, setSide] = useState(600); const onGeomRef = useRef(props.onGeom); onGeomRef.current = props.onGeom; @@ -51,67 +82,80 @@ export const MapView: React.FC = (props) => { const r = el.getBoundingClientRect(); const s = Math.max(200, Math.min(r.width, r.height) - 48); setSide(s); - onGeomRef.current({ w: r.width, h: r.height, side: s }); + geomRef.current = { w: r.width, h: r.height, side: s }; + onGeomRef.current(geomRef.current); } }, []); - const mmToPx = (p: LH2Position) => ({ - left: (p.x / props.mapSize.width) * side, - top: (p.y / props.mapSize.height) * side, + // v1 coordinate convention: y grows NORTH (up); screen top = arena max-y. + const pctPos = (p: LH2Position) => ({ + left: (p.x / props.mapSize.width) * 100, + top: (1 - p.y / props.mapSize.height) * 100, }); const pxToMm = (clientX: number, clientY: number): LH2Position | null => { const el = wrapRef.current; if (!el) return null; const r = el.getBoundingClientRect(); - // Undo camera transform (transform origin is the wrapper center). const cx = r.left + r.width / 2; const cy = r.top + r.height / 2; const ux = (clientX - cx - cam.tx) / cam.scale + r.width / 2; const uy = (clientY - cy - cam.ty) / cam.scale + r.height / 2; - // Arena square is centered in the wrapper. const ax = ux - (r.width - side) / 2; const ay = uy - (r.height - side) / 2; const x = (ax / side) * props.mapSize.width; - const y = (ay / side) * props.mapSize.height; - if (x < 0 || y < 0 || x > props.mapSize.width || y > props.mapSize.height) - return null; + const y = (1 - ay / side) * props.mapSize.height; + if (x < 0 || y < 0 || x > props.mapSize.width || y > props.mapSize.height) return null; return { x: Math.round(x), y: Math.round(y) }; }; + // v1 canvas semantics: alt-click = waypoint, shift-drag = marquee, + // plain drag = pan, plain click (no movement) = clear selection. const onCanvasDown = (e: React.PointerEvent) => { if (e.button !== 0) return; + (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); if (e.altKey) { const p = pxToMm(e.clientX, e.clientY); if (p) props.onAddWaypoint(p); return; } - // Start a marquee from empty canvas. - dragRef.current = { additive: e.shiftKey }; - setMarquee({ x0: e.clientX, y0: e.clientY, x1: e.clientX, y1: e.clientY }); - (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); + if (e.shiftKey) { + marqueeRef.current = { additive: true }; + setMarquee({ x0: e.clientX, y0: e.clientY, x1: e.clientX, y1: e.clientY }); + return; + } + panRef.current = { x0: e.clientX, y0: e.clientY, tx0: cam.tx, ty0: cam.ty, moved: false }; }; const onCanvasMove = (e: React.PointerEvent) => { - if (!dragRef.current) return; - setMarquee((m) => (m ? { ...m, x1: e.clientX, y1: e.clientY } : m)); + if (panRef.current) { + const p = panRef.current; + const dx = e.clientX - p.x0; + const dy = e.clientY - p.y0; + if (Math.abs(dx) + Math.abs(dy) > 3) p.moved = true; + setCam((c) => clampCam({ ...c, tx: p.tx0 + dx, ty: p.ty0 + dy }, geomRef.current)); + return; + } + if (marqueeRef.current) setMarquee((m) => (m ? { ...m, x1: e.clientX, y1: e.clientY } : m)); }; const onCanvasUp = () => { - if (!dragRef.current || !marquee) { - dragRef.current = null; + if (panRef.current) { + const moved = panRef.current.moved; + panRef.current = null; + if (!moved) props.onSelect([], false); // plain click on empty canvas clears + return; + } + if (!marqueeRef.current || !marquee) { + marqueeRef.current = null; setMarquee(null); return; } - const { additive } = dragRef.current; const x0 = Math.min(marquee.x0, marquee.x1); const x1 = Math.max(marquee.x0, marquee.x1); const y0 = Math.min(marquee.y0, marquee.y1); const y1 = Math.max(marquee.y0, marquee.y1); - const isClick = x1 - x0 < 5 && y1 - y0 < 5; - if (isClick) { - props.onSelect([], additive); // click on empty canvas clears - } else { + if (x1 - x0 >= 5 || y1 - y0 >= 5) { const hits = props.bots .filter((b) => { if (!b.position) return false; @@ -123,9 +167,9 @@ export const MapView: React.FC = (props) => { return cx >= x0 && cx <= x1 && cy >= y0 && cy <= y1; }) .map((b) => b.id); - props.onSelect(hits, additive); + props.onSelect(hits, true); } - dragRef.current = null; + marqueeRef.current = null; setMarquee(null); }; @@ -137,6 +181,11 @@ export const MapView: React.FC = (props) => { [gridStep], ); + // Real-scale layer: glyphs scale to the actual DotBot footprint. + const gscale = props.layers.trueScale + ? Math.max(0.2, (side * (REAL_BOT_MM / props.mapSize.width)) / (BOT_R * 2)) + : 1; + return (
= (props) => { onPointerMove={onCanvasMove} onPointerUp={onCanvasUp} onWheel={(e) => - setCam((c) => ({ - ...c, - scale: Math.max(0.5, Math.min(4, c.scale * (e.deltaY < 0 ? 1.1 : 1 / 1.1))), - })) + setCam((c) => + clampCam( + { ...c, scale: Math.max(0.5, Math.min(4, c.scale * (e.deltaY < 0 ? 1.1 : 1 / 1.1))) }, + geomRef.current, + ), + ) } style={{ position: "relative", flex: 1, overflow: "hidden", background: "var(--canvas)", - cursor: "crosshair", + cursor: panRef.current ? "grabbing" : "default", touchAction: "none", }} > @@ -181,30 +232,23 @@ export const MapView: React.FC = (props) => { borderRadius: 6, }} > - {/* trails */} + {/* subtle center accents (v1) */} +
+
+ + {/* trails (our extra layer) */} {props.layers.trails && ( - + {props.bots .filter((b) => b.trail.length > 1) .map((b) => ( { - const q = mmToPx(p); - return `${q.left},${q.top}`; - }) + .map((p) => `${(p.x / props.mapSize.width) * side},${(1 - p.y / props.mapSize.height) * side}`) .join(" ")} fill="none" - stroke={ - b.led - ? `rgb(${b.led.red},${b.led.green},${b.led.blue})` - : "var(--muted)" - } + stroke={ledCss(b)} strokeWidth={1} opacity={0.35} /> @@ -212,64 +256,54 @@ export const MapView: React.FC = (props) => { )} - {/* active missions: waypoints already sent to bots */} + {/* waypoints: per-bot LED-colored diamonds (v1: no connector lines) */} {props.layers.waypoints && - props.bots - .filter((b) => b.waypoints.length > 0 && b.position) - .map((b) => ( - - { - const q = mmToPx(p); - return `${q.left},${q.top}`; - }) - .join(" ")} - fill="none" - stroke="var(--s-Programming)" - strokeWidth={1.5} - strokeDasharray="5 4" - opacity={0.8} + props.bots.flatMap((b) => { + if (b.waypoints.length === 0) return []; + const isSel = props.selection.has(b.id); + const led = ledCss(b); + const s = (isSel ? 10 : 8) * gscale; + return b.waypoints.map((w, i) => { + const q = pctPos(w); + return ( +
- {b.waypoints.map((p, i) => { - const q = mmToPx(p); - return ( - - ); - })} - - ))} + ); + }); + })} - {/* pending (queued, not sent) waypoints */} + {/* pending (queued, not sent) waypoints for the current selection */} {props.layers.waypoints && props.pendingWaypoints.map((p, i) => { - const q = mmToPx(p); + const q = pctPos(p); + const led = props.pendingLed ?? "var(--accent)"; return (
= (props) => { ); })} - {/* bots */} - {props.bots - .filter((b) => b.position) - .map((b) => { - const q = mmToPx(b.position!); - const selected = props.selection.has(b.id); - const ledColor = b.led - ? `rgb(${b.led.red},${b.led.green},${b.led.blue})` - : "var(--s-Inactive)"; - const batPct = Math.max( - 0, - Math.min(100, ((b.battery - 2.0) / (4.2 - 2.0)) * 100), - ); - return ( -
{ - e.stopPropagation(); - props.onSelect([b.id], e.shiftKey); - }} - style={{ - position: "absolute", - left: q.left, - top: q.top, - width: 0, - height: 0, - cursor: "pointer", - }} - title={`${b.id} - ${b.state}${b.drivable ? "" : " (not drivable)"}`} - > - {/* selection rectangle */} - {selected && ( -
- )} - {/* battery bar */} - {props.layers.batteryBars && ( -
-
-
- )} - {/* body: LED-colored circle */} + {/* bots (v1 glyph: state-colored body, LED pip, drive dot, chip label) */} + {props.layers.dotBots && + props.bots + .filter((b) => b.position) + .map((b) => { + const q = pctPos(b.position!); + const selected = props.selection.has(b.id); + const hovered = hoverId === b.id; + const led = ledCss(b); + const stc = `var(--s-${b.state})`; + const pct = batPct(b.battery); + const blink = b.state === "Programming" || b.state === "Resetting"; + return (
{ + e.stopPropagation(); + if (e.altKey) { + const p = pxToMm(e.clientX, e.clientY); + if (p) props.onAddWaypoint(p); + return; + } + props.onSelect([b.id], e.shiftKey || e.metaKey || e.ctrlKey); + }} + onPointerEnter={() => setHoverId(b.id)} + onPointerLeave={() => setHoverId((h) => (h === b.id ? null : h))} style={{ position: "absolute", - left: -BOT_R, - top: -BOT_R, - width: BOT_R * 2, - height: BOT_R * 2, - borderRadius: "50%", - background: ledColor, - opacity: b.state === "Inactive" ? 0.35 : 1, - border: b.drivable - ? "2px solid rgba(255,255,255,.75)" - : "2px solid transparent", - animation: - b.state === "Programming" - ? "dbBlink 1.2s ease-in-out infinite" - : undefined, + left: `${q.left}%`, + top: `${q.top}%`, + transform: `translate(-50%, -50%) scale(${gscale})`, + transition: "left .2s linear, top .2s linear", + cursor: "pointer", + zIndex: selected ? 6 : 2, + width: 0, + height: 0, }} - /> - {/* heading pointer */} - {b.heading !== null && ( + > + {/* selection rectangle */} + {selected && ( +
+ )} + {/* battery bar */} + {props.layers.batteryBars && ( +
+
+
+ )} + {/* body: state-colored circle */}
+ /> + {/* heading pointer (state-colored, at the edge) */} + {b.heading !== null && (
+
+
+ )} + {/* drive dot: white ring at center = drivable */} + {b.drivable && ( +
-
- )} - {/* state pip */} -
- {/* label */} - {props.layers.labels && ( + )} + {/* LED pip (top-right) */}
- {b.id.slice(-4)} -
- )} -
- ); - })} + /> + {/* chip label: selected or hovered only */} + {(selected || hovered) && ( +
+ {b.id.slice(-4).toUpperCase()} +
+ )} +
+ ); + })}
@@ -466,14 +520,8 @@ export const MapView: React.FC = (props) => { onPointerDown={(e) => e.stopPropagation()} > {[ - { - label: "+", - fn: () => setCam((c) => ({ ...c, scale: Math.min(4, c.scale * 1.25) })), - }, - { - label: "−", - fn: () => setCam((c) => ({ ...c, scale: Math.max(0.5, c.scale / 1.25) })), - }, + { label: "+", fn: () => setCam((c) => clampCam({ ...c, scale: Math.min(4, c.scale * 1.25) }, geomRef.current)) }, + { label: "−", fn: () => setCam((c) => clampCam({ ...c, scale: Math.max(0.5, c.scale / 1.25) }, geomRef.current)) }, { label: "◎", fn: () => setCam({ scale: 1, tx: 0, ty: 0 }) }, ].map((z, i) => (
= (props) => { pointerEvents: "none", }} > - drag = select · shift = add · ⌥ alt-click = waypoint + drag = pan · shift-drag = select · ⌥ alt-click = waypoint
); diff --git a/dotbot/console-web/src/Minimap.tsx b/dotbot/console-web/src/Minimap.tsx index a8f0e00c..4e60bf30 100644 --- a/dotbot/console-web/src/Minimap.tsx +++ b/dotbot/console-web/src/Minimap.tsx @@ -1,14 +1,8 @@ import React, { useRef } from "react"; -import { Camera } from "./MapView"; +import { Camera, clampCam, ViewGeom } from "./MapView"; import { MapSize, UnifiedBot } from "./types"; -export interface ViewGeom { - w: number; - h: number; - side: number; -} - interface MinimapProps { bots: UnifiedBot[]; mapSize: MapSize; @@ -18,13 +12,12 @@ interface MinimapProps { } // Whole-arena overview with the current map viewport as a rectangle. -// Dragging moves the camera (the design pans exclusively through here). +// Dragging moves the camera (the design pans through here). The arena box +// keeps the real arena aspect ratio - a 2000x2000 arena is a square. export const Minimap: React.FC = ({ bots, mapSize, cam, setCam, geom }) => { const boxRef = useRef(null); const dragging = useRef(false); - // Fraction of the arena visible in the map wrapper, derived from the camera: - // wrapper coords of arena-fraction f: w/2 + (f - 0.5) * side * scale + tx. const viewportRect = () => { if (!geom) return null; const { w, h, side } = geom; @@ -44,11 +37,12 @@ export const Minimap: React.FC = ({ bots, mapSize, cam, setCam, ge const r = el.getBoundingClientRect(); const fx = Math.max(0, Math.min(1, (clientX - r.left) / r.width)); const fy = Math.max(0, Math.min(1, (clientY - r.top) / r.height)); - setCam((c) => ({ - ...c, - tx: -(fx - 0.5) * geom.side * c.scale, - ty: -(fy - 0.5) * geom.side * c.scale, - })); + setCam((c) => + clampCam( + { ...c, tx: -(fx - 0.5) * geom.side * c.scale, ty: -(fy - 0.5) * geom.side * c.scale }, + geom, + ), + ); }; const rect = viewportRect(); @@ -65,72 +59,67 @@ export const Minimap: React.FC = ({ bots, mapSize, cam, setCam, ge gap: 6, }} > -
+
Arena · {mapSize.width}×{mapSize.height}mm
-
{ - dragging.current = true; - (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); - centerOn(e.clientX, e.clientY); - }} - onPointerMove={(e) => dragging.current && centerOn(e.clientX, e.clientY)} - onPointerUp={() => (dragging.current = false)} - style={{ - position: "relative", - flex: 1, - background: "var(--canvas)", - border: "1px solid var(--hairline)", - borderRadius: 5, - overflow: "hidden", - cursor: "grab", - touchAction: "none", - }} - > - {bots - .filter((b) => b.position) - .map((b) => ( +
+
{ + dragging.current = true; + (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); + centerOn(e.clientX, e.clientY); + }} + onPointerMove={(e) => dragging.current && centerOn(e.clientX, e.clientY)} + onPointerUp={() => (dragging.current = false)} + style={{ + position: "relative", + aspectRatio: `${mapSize.width} / ${mapSize.height}`, + maxWidth: "100%", + maxHeight: "100%", + flex: 1, + background: "var(--canvas)", + border: "1px solid var(--hairline)", + borderRadius: 5, + overflow: "hidden", + cursor: "grab", + touchAction: "none", + }} + > + {bots + .filter((b) => b.position) + .map((b) => ( +
+ ))} + {rect && (
- ))} - {rect && ( -
- )} + )} +
); diff --git a/dotbot/console-web/src/types.ts b/dotbot/console-web/src/types.ts index 4b3f05df..0498d560 100644 --- a/dotbot/console-web/src/types.ts +++ b/dotbot/console-web/src/types.ts @@ -32,6 +32,7 @@ export interface PyDotBot { address: string; application: number; // ApplicationType: 0 = DotBot status: number; // 0 ACTIVE, 1 INACTIVE, 2 LOST + mode?: number; // ControlModeType: 0 MANUAL, 1 AUTO (navigating waypoints) direction?: number; lh2_position?: LH2Position; position_history?: LH2Position[]; @@ -69,6 +70,7 @@ export interface UnifiedBot { deviceType: string; application: number; drivable: boolean; // a DBP-speaking image is running (= known to PyDotBot and active) + nav: "drive" | "auto"; // auto = navigating waypoints (firmware AUTO mode) waypoints: LH2Position[]; // active mission (as reported by the controller) trail: LH2Position[]; } diff --git a/dotbot/console-web/src/useFleet.ts b/dotbot/console-web/src/useFleet.ts index 21b55b99..7a6aff82 100644 --- a/dotbot/console-web/src/useFleet.ts +++ b/dotbot/console-web/src/useFleet.ts @@ -56,6 +56,7 @@ function merge( // control plane and active. SwarmIT-only bots (e.g. sitting in the // bootloader) are not drivable. drivable: !!py && py.status === 0 && state === "Running", + nav: py?.mode === 1 ? "auto" : "drive", waypoints: py?.waypoints ?? [], trail: py?.position_history?.slice(-TRAIL_MAX) ?? [], }); From f54066a3c62601ba826697b39f9f99b44368b529 Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Thu, 9 Jul 2026 14:55:34 +0200 Subject: [PATCH 06/27] dotbot/console-web: drop wheel zoom (not in the design; zoom buttons only) AI-assisted: Claude Fable 5 --- dotbot/console-web/src/MapView.tsx | 8 -------- 1 file changed, 8 deletions(-) diff --git a/dotbot/console-web/src/MapView.tsx b/dotbot/console-web/src/MapView.tsx index d709a107..8da69726 100644 --- a/dotbot/console-web/src/MapView.tsx +++ b/dotbot/console-web/src/MapView.tsx @@ -192,14 +192,6 @@ export const MapView: React.FC = (props) => { onPointerDown={onCanvasDown} onPointerMove={onCanvasMove} onPointerUp={onCanvasUp} - onWheel={(e) => - setCam((c) => - clampCam( - { ...c, scale: Math.max(0.5, Math.min(4, c.scale * (e.deltaY < 0 ? 1.1 : 1 / 1.1))) }, - geomRef.current, - ), - ) - } style={{ position: "relative", flex: 1, From fb4dabd2a86c6d62fa5be1ab97303435cd047099 Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Thu, 9 Jul 2026 15:03:52 +0200 Subject: [PATCH 07/27] dotbot/console-web: add testbed rail with missions panel AI-assisted: Claude Fable 5 --- dotbot/console-web/src/App.tsx | 90 ++--- dotbot/console-web/src/Footer.tsx | 6 +- dotbot/console-web/src/TestbedRail.tsx | 479 +++++++++++++++++++++++++ dotbot/console-web/src/useFleet.ts | 8 + 4 files changed, 528 insertions(+), 55 deletions(-) create mode 100644 dotbot/console-web/src/TestbedRail.tsx diff --git a/dotbot/console-web/src/App.tsx b/dotbot/console-web/src/App.tsx index 9eb8914e..eaa0244e 100644 --- a/dotbot/console-web/src/App.tsx +++ b/dotbot/console-web/src/App.tsx @@ -5,6 +5,7 @@ import { Footer } from "./Footer"; import { GridView } from "./GridView"; import { ListView } from "./ListView"; import { Camera, Layers, MapView, ViewGeom } from "./MapView"; +import { DoneMission, TestbedRail } from "./TestbedRail"; import { LH2Position } from "./types"; import { useFleet } from "./useFleet"; @@ -12,20 +13,6 @@ const WAYPOINT_THRESHOLD = 60; // mm, arrival radius sent with waypoint missions type ViewKind = "map" | "list" | "grid"; -// Collapsed testbed rail (v1). Orchestration actions arrive with the -// write-path iteration; until then the icons are visible but inert. -const RAIL_ICONS: { glyph: string; title: string }[] = [ - { glyph: "▱", title: "Open testbed (arrives with orchestration)" }, - { glyph: "", title: "" }, // divider - { glyph: "⇩", title: "Flash (arrives with orchestration)" }, - { glyph: "▶", title: "Start (arrives with orchestration)" }, - { glyph: "■", title: "Stop (arrives with orchestration)" }, - { glyph: "↻", title: "Reset (arrives with orchestration)" }, - { glyph: "", title: "" }, // divider - { glyph: "☰", title: "Console (arrives with orchestration)" }, - { glyph: "⋮", title: "Flash queue (arrives with orchestration)" }, -]; - export const App: React.FC = () => { const { bots, mapSize, wsUp } = useFleet(); const [theme, setTheme] = useState<"dark" | "light">("dark"); @@ -117,6 +104,31 @@ export const App: React.FC = () => { const onClearQueue = useCallback(() => setPending([]), []); const onRemovePending = useCallback((i: number) => setPending((prev) => prev.filter((_, j) => j !== i)), []); + // Recently-completed missions: a bot flipping AUTO -> MANUAL just arrived. + const [doneMissions, setDoneMissions] = useState([]); + const prevNavRef = useRef>({}); + React.useEffect(() => { + const prev = prevNavRef.current; + const arrived = bots.filter((b) => prev[b.id] === "auto" && b.nav === "drive"); + if (arrived.length) { + const t = new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); + setDoneMissions((d) => + [...arrived.map((b) => ({ key: `${b.id}-${Date.now()}`, id: b.id.slice(-4).toUpperCase(), t })), ...d].slice(0, 8), + ); + } + prevNavRef.current = Object.fromEntries(bots.map((b) => [b.id, b.nav])); + }, [bots]); + + const onStopMission = useCallback( + (ids: string[]) => { + bots + .filter((b) => ids.includes(b.id) && b.drivable) + .forEach((b) => putWaypoints(b.id, b.application, WAYPOINT_THRESHOLD, []).catch(() => {})); + showToast("Mission interrupted"); + }, + [bots, showToast], + ); + const layerRows: { key: keyof Layers; label: string }[] = [ { key: "batteryBars", label: "Battery Bars" }, { key: "waypoints", label: "Waypoints" }, @@ -223,46 +235,16 @@ export const App: React.FC = () => { {/* Body row: testbed rail + view area */}
- {/* collapsed testbed rail (shell; orchestration arrives later) */} -
- {RAIL_ICONS.map((ic, i) => - ic.glyph === "" ? ( -
- ) : ( -
- {ic.glyph} -
- ), - )} -
+ onSelect(ids, false)} + onGo={onGo} + onDiscardPlanned={onClearQueue} + onStopMission={onStopMission} + /> {/* view area */}
diff --git a/dotbot/console-web/src/Footer.tsx b/dotbot/console-web/src/Footer.tsx index 647007a4..c8607f4d 100644 --- a/dotbot/console-web/src/Footer.tsx +++ b/dotbot/console-web/src/Footer.tsx @@ -92,7 +92,11 @@ const ControlDock: React.FC<{ const drivable = targets.filter((b) => b.drivable); const enabled = drivable.length > 0; const anyAuto = drivable.some((b) => b.nav === "auto"); - const activeCount = drivable.reduce((a, b) => a + b.waypoints.length, 0); + // The controller stores [own-start, ...targets]; count the targets. + const activeCount = Math.max( + ...drivable.map((b) => (b.waypoints.length > 1 ? b.waypoints.length - 1 : b.waypoints.length)), + 0, + ); const wpCount = pending.length > 0 ? pending.length : anyAuto ? activeCount : 0; const single = !isGroup ? targets[0] : undefined; diff --git a/dotbot/console-web/src/TestbedRail.tsx b/dotbot/console-web/src/TestbedRail.tsx new file mode 100644 index 00000000..d11f45ab --- /dev/null +++ b/dotbot/console-web/src/TestbedRail.tsx @@ -0,0 +1,479 @@ +import React, { useState } from "react"; + +import { LH2Position, UnifiedBot } from "./types"; + +// Left testbed rail, per v1: collapsed 52px icon strip <-> 340px panel with a +// Testbed tab (orchestration controls - disabled until the swarmit write path +// lands; never mocked) and a Missions tab (waypoint missions derived from +// live state: Planned = the local queue, Active = bots navigating). + +export interface DoneMission { + key: string; + id: string; // short label + t: string; // time string +} + +interface Mission { + key: string; + ids: string[]; + label: string; + count: number; + n: number; // waypoints (max left among the group) + phase: "planned" | "active"; + dots: string[]; // led css colors, max 4 +} + +interface TestbedRailProps { + bots: UnifiedBot[]; + selection: Set; + pending: LH2Position[]; + doneMissions: DoneMission[]; + onSelectIds: (ids: string[]) => void; + onGo: () => void; + onDiscardPlanned: () => void; + onStopMission: (ids: string[]) => void; +} + +const ledCss = (b: UnifiedBot) => + b.led ? `rgb(${b.led.red},${b.led.green},${b.led.blue})` : "var(--s-Inactive)"; +const short = (id: string) => id.slice(-4).toUpperCase(); +const label10 = { fontSize: 10, letterSpacing: ".5px", textTransform: "uppercase", color: "var(--muted)" } as const; + +// v1 actBtn, rail variant (full width), rendered disabled until orchestration. +const railBtn = (accent: boolean): React.CSSProperties => ({ + display: "flex", + alignItems: "center", + justifyContent: "center", + gap: 6, + padding: "7px 13px", + borderRadius: 7, + fontSize: 12, + fontWeight: 500, + whiteSpace: "nowrap", + border: `1px solid ${accent ? "var(--accent)" : "var(--hairline)"}`, + background: accent ? "var(--accent)" : "var(--elevated)", + color: accent ? "#fff" : "var(--text)", + width: "100%", + boxSizing: "border-box", + cursor: "not-allowed", + opacity: 0.55, +}); + +const tabStyle = (active: boolean): React.CSSProperties => ({ + padding: "5px 11px", + borderRadius: 5, + fontSize: 12, + fontWeight: 500, + cursor: "pointer", + background: active ? "var(--elevated)" : "transparent", + color: active ? "var(--text)" : "var(--muted)", +}); + +const topTabStyle = (active: boolean): React.CSSProperties => ({ + padding: "5px 12px", + borderRadius: 6, + fontSize: 12, + fontWeight: active ? 600 : 500, + cursor: "pointer", + background: active ? "var(--accent)" : "transparent", + color: active ? "#fff" : "var(--muted)", + display: "flex", + alignItems: "center", + gap: 6, +}); + +export function deriveMissions( + bots: UnifiedBot[], + selection: Set, + pending: LH2Position[], +): Mission[] { + const missions: Mission[] = []; + // Planned: the local queue on the current drivable selection. + const drivableSel = bots.filter((b) => selection.has(b.id) && b.drivable); + if (pending.length > 0 && drivableSel.length > 0) { + missions.push({ + key: "planned", + ids: drivableSel.map((b) => b.id), + label: drivableSel.length === 1 ? short(drivableSel[0].id) : `${drivableSel.length} bots`, + count: drivableSel.length, + n: pending.length, + phase: "planned", + dots: drivableSel.slice(0, 4).map(ledCss), + }); + } + // Active: navigating bots, grouped by identical mission targets. The + // controller prepends each bot's own start position to the list it stores, + // so the shared mission is the TAIL - skip the first element when the list + // has more than one entry. + const targetsOf = (b: UnifiedBot) => (b.waypoints.length > 1 ? b.waypoints.slice(1) : b.waypoints); + const navving = bots.filter((b) => b.nav === "auto" && b.waypoints.length > 0); + const groups = new Map(); + navving.forEach((b) => { + const sig = targetsOf(b) + .map((w) => `${Math.round(w.x)},${Math.round(w.y)}`) + .join(";"); + const arr = groups.get(sig) ?? []; + arr.push(b); + groups.set(sig, arr); + }); + [...groups.entries()].forEach(([sig, bs]) => { + missions.push({ + key: `active-${sig}`, + ids: bs.map((b) => b.id), + label: bs.length === 1 ? short(bs[0].id) : `${bs.length} bots`, + count: bs.length, + n: Math.max(...bs.map((b) => targetsOf(b).length)), + phase: "active", + dots: bs.slice(0, 4).map(ledCss), + }); + }); + return missions; +} + +export const TestbedRail: React.FC = (props) => { + // ?rail=testbed|missions opens the panel on a tab (handy for dev/screenshots). + const railParam = new URLSearchParams(window.location.search).get("rail"); + const [mode, setMode] = useState<"collapsed" | "panel">( + railParam === "testbed" || railParam === "missions" ? "panel" : "collapsed", + ); + const [top, setTop] = useState<"testbed" | "missions">(railParam === "missions" ? "missions" : "testbed"); + const [tab, setTab] = useState<"console" | "flash">("console"); + + const missions = deriveMissions(props.bots, props.selection, props.pending); + const targetLabel = props.selection.size ? `${props.selection.size} selected` : "whole fleet"; + const orchTitle = "Arrives with orchestration (swarmit write path is read-only for now)"; + + const ico: React.CSSProperties = { + width: 32, + height: 32, + display: "flex", + alignItems: "center", + justifyContent: "center", + borderRadius: 7, + background: "var(--elevated)", + border: "1px solid var(--hairline)", + fontSize: 13, + color: "var(--text)", + }; + + return ( +
+ {/* collapsed icon strip */} + {mode === "collapsed" && ( +
+
setMode("panel")} title="Open testbed" style={{ ...ico, cursor: "pointer" }}> + ▤ +
+
+ {[ + { g: "⇩", t: `Flash - ${orchTitle}` }, + { g: "▶", t: `Start - ${orchTitle}` }, + { g: "■", t: `Stop - ${orchTitle}` }, + { g: "↻", t: `Reset - ${orchTitle}` }, + ].map((x, i) => ( +
+ {x.g} +
+ ))} +
+
{ + setMode("panel"); + setTop("testbed"); + setTab("console"); + }} + title="Console" + style={{ ...ico, cursor: "pointer" }} + > + ☰ +
+
{ + setMode("panel"); + setTop("missions"); + }} + title={`Missions (${missions.length})`} + style={{ ...ico, cursor: "pointer", position: "relative" }} + > + ◎ + {missions.length > 0 && ( + + {missions.length} + + )} +
+
+ )} + + {/* full panel */} + {mode === "panel" && ( +
+ {/* header */} +
+
+
setTop("testbed")} style={topTabStyle(top === "testbed")}> + Testbed +
+
setTop("missions")} style={topTabStyle(top === "missions")}> + Missions{" "} + + {missions.length} + +
+
+
+ setMode("collapsed")} + title="Collapse" + style={{ cursor: "pointer", color: "var(--muted)", fontSize: 15, lineHeight: 1 }} + > + ‹ + +
+ + {/* TESTBED tab */} + {top === "testbed" && ( +
+
+
+ Target · {targetLabel} +
+
+
+ ⇩ Flash… +
+
+ ▶ Start +
+
+ ■ Stop +
+
+ ↻ Reset +
+
+
+
+
setTab("console")} style={tabStyle(tab === "console")}> + Console +
+
setTab("flash")} style={tabStyle(tab === "flash")}> + Flash queue +
+
+ {tab === "console" && ( +
+
+ + /events · log_event + +
+
+
+ Live testbed logs arrive with the orchestration iteration (swarmit /events SSE). +
+
+
+ )} + {tab === "flash" && ( +
+
+ No active flash. Pick targets in any view (or none = whole fleet), then Flash… +
+
+ )} +
+ )} + + {/* MISSIONS tab */} + {top === "missions" && ( +
+
+ Active waypoint missions +
+ {missions.length > 0 && ( + props.onSelectIds(missions.flatMap((m) => m.ids))} + style={{ fontSize: 11, color: "var(--accent)", cursor: "pointer" }} + > + Select all + + )} +
+
+ {missions.map((m) => ( +
props.onSelectIds(m.ids)} + style={{ + display: "flex", + alignItems: "center", + gap: 10, + padding: "9px 10px", + background: "var(--elevated)", + border: "1px solid var(--hairline)", + borderRadius: 8, + marginBottom: 8, + cursor: "pointer", + }} + > +
+ {m.dots.map((c, i) => ( +
+ ))} +
+ {m.label} + + {m.phase === "active" ? "Active" : "Planned"} + +
+ + ◎ {m.n} + + {m.phase === "planned" && ( +
{ + e.stopPropagation(); + props.onGo(); + }} + style={{ + display: "inline-flex", + alignItems: "center", + padding: "3px 10px", + borderRadius: 6, + fontSize: 11, + fontWeight: 600, + cursor: "pointer", + background: "var(--accent)", + color: "#fff", + }} + > + ▶ Go +
+ )} +
{ + e.stopPropagation(); + if (m.phase === "planned") props.onDiscardPlanned(); + else props.onStopMission(m.ids); + }} + title={m.phase === "active" ? "Interrupt & discard mission" : "Discard mission"} + style={{ + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + width: 22, + height: 22, + borderRadius: 6, + cursor: "pointer", + color: "var(--muted)", + fontSize: 16, + }} + > + × +
+
+ ))} + {missions.length === 0 && ( +
+ No missions yet. +
+
+ Select bots · ⌥ Alt-click the map to add waypoints (mission shows as Planned) · + press Go to make it Active. Click a mission to reselect its bots. +
+ )} + {props.doneMissions.length > 0 && ( +
+
Recently completed
+ {props.doneMissions.map((d) => ( +
+ + {d.id} + reached all waypoints +
+ {d.t} +
+ ))} +
+ )} +
+
+ )} +
+ )} +
+ ); +}; diff --git a/dotbot/console-web/src/useFleet.ts b/dotbot/console-web/src/useFleet.ts index 7a6aff82..ddcf9ee5 100644 --- a/dotbot/console-web/src/useFleet.ts +++ b/dotbot/console-web/src/useFleet.ts @@ -155,6 +155,14 @@ export function useFleet(): { }; }, [reloadDotBots, rebuild]); + // Slow refresh for fields the WS does not push (mode/nav, status, waypoint + // clears): the controller only notifies telemetry deltas, so a bot's + // AUTO -> MANUAL arrival flip is invisible without an occasional refetch. + useEffect(() => { + const t = setInterval(reloadDotBots, 3000); + return () => clearInterval(t); + }, [reloadDotBots]); + // SwarmIT status poll (read-only orchestration plane), 1 Hz. useEffect(() => { const tick = async () => { From 222cd955e23ece985ca92190dfaeb0eb2a5143cb Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Thu, 9 Jul 2026 15:25:58 +0200 Subject: [PATCH 08/27] dotbot/console-web: persistent planned missions, square minimap, taller footer AI-assisted: Claude Fable 5 --- dotbot/console-web/src/App.tsx | 93 ++++++++++++++++++-------- dotbot/console-web/src/Footer.tsx | 4 +- dotbot/console-web/src/MapView.tsx | 43 +++++------- dotbot/console-web/src/Minimap.tsx | 3 +- dotbot/console-web/src/TestbedRail.tsx | 42 ++++++------ dotbot/console-web/src/types.ts | 8 +++ 6 files changed, 113 insertions(+), 80 deletions(-) diff --git a/dotbot/console-web/src/App.tsx b/dotbot/console-web/src/App.tsx index eaa0244e..a142452f 100644 --- a/dotbot/console-web/src/App.tsx +++ b/dotbot/console-web/src/App.tsx @@ -6,7 +6,7 @@ import { GridView } from "./GridView"; import { ListView } from "./ListView"; import { Camera, Layers, MapView, ViewGeom } from "./MapView"; import { DoneMission, TestbedRail } from "./TestbedRail"; -import { LH2Position } from "./types"; +import { LH2Position, PlannedMission } from "./types"; import { useFleet } from "./useFleet"; const WAYPOINT_THRESHOLD = 60; // mm, arrival radius sent with waypoint missions @@ -45,7 +45,8 @@ export const App: React.FC = () => { preselRef.current = true; }, [bots]); - const [pending, setPending] = useState([]); + // Planned missions: local waypoint queues bound to bots at queue time. + const [planned, setPlanned] = useState([]); const [layersOpen, setLayersOpen] = useState(false); const [layers, setLayers] = useState({ batteryBars: true, @@ -63,36 +64,55 @@ export const App: React.FC = () => { ids.forEach((id) => (next.has(id) ? next.delete(id) : next.add(id))); return next; }); - if (!additive && ids.length === 0) setPending([]); }, []); const selectedBots = bots.filter((b) => selection.has(b.id)); const drivableSelected = selectedBots.filter((b) => b.drivable); - const pendingLed = - drivableSelected.length > 0 && drivableSelected[0].led - ? `rgb(${drivableSelected[0].led.red},${drivableSelected[0].led.green},${drivableSelected[0].led.blue})` - : null; + const selKey = drivableSelected.map((b) => b.id).sort().join("-"); + const selPlanned = planned.find((m) => m.key === selKey); + const pending = selPlanned?.waypoints ?? []; const onAddWaypoint = useCallback( (p: LH2Position) => { if (drivableSelected.length === 0) return; - setPending((prev) => [...prev, p]); + const ids = drivableSelected.map((b) => b.id).sort(); + const key = ids.join("-"); + setPlanned((prev) => { + const hit = prev.find((m) => m.key === key); + if (hit) return prev.map((m) => (m.key === key ? { ...m, waypoints: [...m.waypoints, p] } : m)); + return [...prev, { key, ids, waypoints: [p] }]; + }); }, - [drivableSelected.length], + [drivableSelected], + ); + + const sendMission = useCallback( + (m: PlannedMission) => { + const targets = bots.filter((b) => m.ids.includes(b.id) && b.drivable); + targets.forEach((b) => { + putWaypoints(b.id, b.application, WAYPOINT_THRESHOLD, m.waypoints).catch(() => {}); + }); + showToast( + `${m.waypoints.length} waypoint${m.waypoints.length > 1 ? "s" : ""} sent to ${targets.length} bot${ + targets.length > 1 ? "s" : "" + }`, + ); + setPlanned((prev) => prev.filter((x) => x.key !== m.key)); + }, + [bots, showToast], ); const onGo = useCallback(() => { - if (pending.length === 0) return; - drivableSelected.forEach((b) => { - putWaypoints(b.id, b.application, WAYPOINT_THRESHOLD, pending).catch(() => {}); - }); - showToast( - `${pending.length} waypoint${pending.length > 1 ? "s" : ""} sent to ${drivableSelected.length} bot${ - drivableSelected.length > 1 ? "s" : "" - }`, - ); - setPending([]); - }, [pending, drivableSelected, showToast]); + if (selPlanned) sendMission(selPlanned); + }, [selPlanned, sendMission]); + + const onGoMission = useCallback( + (key: string) => { + const m = planned.find((x) => x.key === key); + if (m) sendMission(m); + }, + [planned, sendMission], + ); const onStopNav = useCallback(() => { drivableSelected.forEach((b) => { @@ -101,8 +121,22 @@ export const App: React.FC = () => { if (drivableSelected.length > 0) showToast("Navigation stopped"); }, [drivableSelected, showToast]); - const onClearQueue = useCallback(() => setPending([]), []); - const onRemovePending = useCallback((i: number) => setPending((prev) => prev.filter((_, j) => j !== i)), []); + const onClearQueue = useCallback(() => { + setPlanned((prev) => prev.filter((m) => m.key !== selKey)); + }, [selKey]); + const onDiscardMission = useCallback((key: string) => { + setPlanned((prev) => prev.filter((m) => m.key !== key)); + }, []); + const onRemovePending = useCallback( + (i: number) => { + setPlanned((prev) => + prev + .map((m) => (m.key === selKey ? { ...m, waypoints: m.waypoints.filter((_, j) => j !== i) } : m)) + .filter((m) => m.waypoints.length > 0), + ); + }, + [selKey], + ); // Recently-completed missions: a bot flipping AUTO -> MANUAL just arrived. const [doneMissions, setDoneMissions] = useState([]); @@ -238,11 +272,11 @@ export const App: React.FC = () => { onSelect(ids, false)} - onGo={onGo} - onDiscardPlanned={onClearQueue} + onGoMission={onGoMission} + onDiscardMission={onDiscardMission} onStopMission={onStopMission} /> @@ -254,8 +288,13 @@ export const App: React.FC = () => { mapSize={mapSize} selection={selection} layers={layers} - pendingWaypoints={pending} - pendingLed={pendingLed} + plannedMissions={planned.map((m) => { + const owner = bots.find((b) => m.ids.includes(b.id) && b.led); + return { + waypoints: m.waypoints, + led: owner?.led ? `rgb(${owner.led.red},${owner.led.green},${owner.led.blue})` : null, + }; + })} cam={cam} setCam={setCam} onGeom={setGeom} diff --git a/dotbot/console-web/src/Footer.tsx b/dotbot/console-web/src/Footer.tsx index c8607f4d..716ae846 100644 --- a/dotbot/console-web/src/Footer.tsx +++ b/dotbot/console-web/src/Footer.tsx @@ -313,7 +313,7 @@ const ControlDock: React.FC<{ ); }; -const Sep: React.FC = () =>
; +const Sep: React.FC = () =>
; export const Footer: React.FC = (props) => { const selected = props.bots.filter((b) => props.selection.has(b.id)); @@ -322,7 +322,7 @@ export const Footer: React.FC = (props) => { return (
; layers: Layers; - pendingWaypoints: LH2Position[]; // local queue, not yet sent - pendingLed: string | null; // css color of the queue's owner selection + plannedMissions: { waypoints: LH2Position[]; led: string | null }[]; // local queues, not yet sent cam: Camera; setCam: React.Dispatch>; onGeom: (g: ViewGeom) => void; @@ -278,14 +277,15 @@ export const MapView: React.FC = (props) => { }); })} - {/* pending (queued, not sent) waypoints for the current selection */} + {/* planned (queued, not sent) waypoints */} {props.layers.waypoints && - props.pendingWaypoints.map((p, i) => { + props.plannedMissions.flatMap((m, mi) => + m.waypoints.map((p, i) => { const q = pctPos(p); - const led = props.pendingLed ?? "var(--accent)"; + const led = m.led ?? "var(--accent)"; return (
= (props) => { title={`waypoint ${i + 1}`} /> ); - })} + }), + )} {/* bots (v1 glyph: state-colored body, LED pip, drive dot, chip label) */} {props.layers.dotBots && @@ -417,38 +418,26 @@ export const MapView: React.FC = (props) => { />
)} - {/* drive dot: white ring at center = drivable */} + {/* drive dot: white ring at center = drivable; its FILL is + the LED color (experiment: merges the v1 LED pip into the + drive indicator - see design-feedback) */} {b.drivable && (
)} - {/* LED pip (top-right) */} -
{/* chip label: selected or hovered only */} {(selected || hovered) && (
= ({ bots, mapSize, cam, setCam, ge style={{ position: "relative", aspectRatio: `${mapSize.width} / ${mapSize.height}`, + height: "100%", maxWidth: "100%", - maxHeight: "100%", - flex: 1, background: "var(--canvas)", border: "1px solid var(--hairline)", borderRadius: 5, diff --git a/dotbot/console-web/src/TestbedRail.tsx b/dotbot/console-web/src/TestbedRail.tsx index d11f45ab..67abca5f 100644 --- a/dotbot/console-web/src/TestbedRail.tsx +++ b/dotbot/console-web/src/TestbedRail.tsx @@ -1,6 +1,6 @@ import React, { useState } from "react"; -import { LH2Position, UnifiedBot } from "./types"; +import { PlannedMission, UnifiedBot } from "./types"; // Left testbed rail, per v1: collapsed 52px icon strip <-> 340px panel with a // Testbed tab (orchestration controls - disabled until the swarmit write path @@ -26,11 +26,11 @@ interface Mission { interface TestbedRailProps { bots: UnifiedBot[]; selection: Set; - pending: LH2Position[]; + planned: PlannedMission[]; doneMissions: DoneMission[]; onSelectIds: (ids: string[]) => void; - onGo: () => void; - onDiscardPlanned: () => void; + onGoMission: (key: string) => void; + onDiscardMission: (key: string) => void; onStopMission: (ids: string[]) => void; } @@ -82,25 +82,23 @@ const topTabStyle = (active: boolean): React.CSSProperties => ({ gap: 6, }); -export function deriveMissions( - bots: UnifiedBot[], - selection: Set, - pending: LH2Position[], -): Mission[] { +export function deriveMissions(bots: UnifiedBot[], planned: PlannedMission[]): Mission[] { const missions: Mission[] = []; - // Planned: the local queue on the current drivable selection. - const drivableSel = bots.filter((b) => selection.has(b.id) && b.drivable); - if (pending.length > 0 && drivableSel.length > 0) { + const byId = new Map(bots.map((b) => [b.id, b])); + // Planned: local queues, bound to their bots at queue time. + planned.forEach((m) => { + const bs = m.ids.map((id) => byId.get(id)).filter(Boolean) as UnifiedBot[]; + if (!bs.length) return; missions.push({ - key: "planned", - ids: drivableSel.map((b) => b.id), - label: drivableSel.length === 1 ? short(drivableSel[0].id) : `${drivableSel.length} bots`, - count: drivableSel.length, - n: pending.length, + key: m.key, + ids: m.ids, + label: bs.length === 1 ? short(bs[0].id) : `${bs.length} bots`, + count: bs.length, + n: m.waypoints.length, phase: "planned", - dots: drivableSel.slice(0, 4).map(ledCss), + dots: bs.slice(0, 4).map(ledCss), }); - } + }); // Active: navigating bots, grouped by identical mission targets. The // controller prepends each bot's own start position to the list it stores, // so the shared mission is the TAIL - skip the first element when the list @@ -139,7 +137,7 @@ export const TestbedRail: React.FC = (props) => { const [top, setTop] = useState<"testbed" | "missions">(railParam === "missions" ? "missions" : "testbed"); const [tab, setTab] = useState<"console" | "flash">("console"); - const missions = deriveMissions(props.bots, props.selection, props.pending); + const missions = deriveMissions(props.bots, props.planned); const targetLabel = props.selection.size ? `${props.selection.size} selected` : "whole fleet"; const orchTitle = "Arrives with orchestration (swarmit write path is read-only for now)"; @@ -403,7 +401,7 @@ export const TestbedRail: React.FC = (props) => {
{ e.stopPropagation(); - props.onGo(); + props.onGoMission(m.key); }} style={{ display: "inline-flex", @@ -423,7 +421,7 @@ export const TestbedRail: React.FC = (props) => {
{ e.stopPropagation(); - if (m.phase === "planned") props.onDiscardPlanned(); + if (m.phase === "planned") props.onDiscardMission(m.key); else props.onStopMission(m.ids); }} title={m.phase === "active" ? "Interrupt & discard mission" : "Discard mission"} diff --git a/dotbot/console-web/src/types.ts b/dotbot/console-web/src/types.ts index 0498d560..3acd02c7 100644 --- a/dotbot/console-web/src/types.ts +++ b/dotbot/console-web/src/types.ts @@ -79,3 +79,11 @@ export interface MapSize { width: number; height: number; } + +// A waypoint mission queued locally but not yet sent: bound to the bots that +// were selected when its waypoints were dropped (survives deselection). +export interface PlannedMission { + key: string; // sorted ids joined + ids: string[]; + waypoints: LH2Position[]; +} From 599627ca1d3316790317aac4947027a485bcf243 Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Thu, 9 Jul 2026 15:34:04 +0200 Subject: [PATCH 09/27] dotbot/console-web: upgrade fake swarmit to a stateful write-capable simulator AI-assisted: Claude Fable 5 --- dotbot/console-web/dev/fake_swarmit_server.py | 212 +++++++++++++++--- 1 file changed, 182 insertions(+), 30 deletions(-) diff --git a/dotbot/console-web/dev/fake_swarmit_server.py b/dotbot/console-web/dev/fake_swarmit_server.py index 6ba05f05..c7eed8d1 100644 --- a/dotbot/console-web/dev/fake_swarmit_server.py +++ b/dotbot/console-web/dev/fake_swarmit_server.py @@ -1,82 +1,115 @@ -"""Fake SwarmIT status server for console development. +"""Fake SwarmIT server for console development. -Serves the two read-only endpoints the console consumes, shaped exactly like -swarmit's webserver: +Simulates the swarmit testbed server's HTTP surface with the same shapes: -- GET /status -> {"response": {"": {device, status, battery, pos_x, pos_y}}} -- GET /settings -> {network_id, area_width, area_height, calibration_distance, auth_mode} +- GET /status -> {"response": {"": {device, status, battery, pos_x, pos_y}}} +- GET /settings -> {network_id, area_width, area_height, calibration_distance, auth_mode} +- POST /start|/stop|/reset {devices?} -> state transitions +- POST /flash {firmware_b64, devices?} (blocking) +- POST /flash/stream {firmware_b64, devices?} -> SSE: flash_started / chunk / + device_done / complete (same event shapes as swarmit) +- GET /events -> SSE: log_event entries (+ periodic status snapshots) -Bot addresses are read live from the PyDotBot controller (GET /controller/dotbots) -so the two planes join on the same ids. States are simulated: everything Running, -except a periodic Programming episode that sweeps the fleet, plus two ghost bots -that exist only on the orchestration plane (state Bootloader, not drivable) to -exercise the console's conditional-controllability UI. +Bot addresses are read live from the PyDotBot controller so both planes join +on the same ids; two ghost bots exist only on this plane (start in Bootloader, +never drivable). The DEVICE side is simulated; the HTTP contract is the real +one, so the console's write path exercises the same calls it will make against +a real swarmit server. Usage: python fake_swarmit_server.py [--controller http://localhost:8000] [--port 8001] """ import argparse +import asyncio import json import time import urllib.request import uvicorn -from fastapi import FastAPI +from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import StreamingResponse GHOSTS = { "C0FFEE0000000001": {"pos_x": 300, "pos_y": 1800}, "C0FFEE0000000002": {"pos_x": 1750, "pos_y": 1650}, } -PROGRAMMING_PERIOD_S = 25 # a bot enters Programming this often -PROGRAMMING_LASTS_S = 8 +TOTAL_CHUNKS = 320 +CHUNKS_PER_TICK = 12 +TICK_S = 0.15 app = FastAPI(title="fake-swarmit") app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_methods=["*"], - allow_headers=["*"], + CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"] ) settings = {"controller": "http://localhost:8000"} +# Per-device simulated orchestration state. +states: dict = {} # addr -> {"status": str, "progress": {"acked", "total"} | None} +events: list = [] # [{"id", "type": "log_event", "level", "message", "ts"}] +_event_seq = 0 + + +def push_log(level: str, message: str) -> None: + global _event_seq + _event_seq += 1 + events.append( + {"id": _event_seq, "type": "log_event", "level": level, "message": message, "ts": time.time()} + ) + del events[:-200] + def fetch_dotbots() -> list: try: - with urllib.request.urlopen( - f"{settings['controller']}/controller/dotbots", timeout=2 - ) as res: + with urllib.request.urlopen(f"{settings['controller']}/controller/dotbots", timeout=2) as res: return json.loads(res.read()) except Exception: return [] +def known_devices() -> list: + return sorted({b["address"] for b in fetch_dotbots()} | set(GHOSTS)) + + +def state_of(addr: str) -> dict: + if addr not in states: + states[addr] = {"status": "Bootloader" if addr in GHOSTS else "Running", "progress": None} + return states[addr] + + +def resolve_devices(payload_devices) -> list: + if not payload_devices: + return known_devices() + if isinstance(payload_devices, str): + return [payload_devices] + return list(payload_devices) + + +def _sse(data: dict) -> str: + return f"data: {json.dumps(data)}\n\n" + + @app.get("/status") def status(): - now = time.time() response = {} - bots = fetch_dotbots() - for i, bot in enumerate(sorted(bots, key=lambda b: b["address"])): + for bot in fetch_dotbots(): addr = bot["address"] - state = "Running" - # Deterministic Programming sweep: bot i is Programming during its slot. - slot = int(now // PROGRAMMING_PERIOD_S) % max(len(bots), 1) - if i == slot and (now % PROGRAMMING_PERIOD_S) < PROGRAMMING_LASTS_S: - state = "Programming" + st = state_of(addr) pos = bot.get("lh2_position") or {} response[addr] = { "device": "DotBotV3", - "status": state, + "status": st["status"], "battery": int(float(bot.get("battery", 3.0)) * 1000), "pos_x": int(pos.get("x", 0)), "pos_y": int(pos.get("y", 0)), } for addr, pos in GHOSTS.items(): + st = state_of(addr) response[addr] = { "device": "DotBotV3", - "status": "Bootloader", + "status": st["status"], "battery": 3900, "pos_x": pos["pos_x"], "pos_y": pos["pos_y"], @@ -95,6 +128,125 @@ def get_settings(): } +async def transition(devices: list, via: str, to: str, delay: float) -> None: + for a in devices: + state_of(a)["status"] = via + await asyncio.sleep(delay) + for a in devices: + state_of(a)["status"] = to + + +@app.post("/start") +async def start(request: Request): + body = await request.json() if int(request.headers.get("content-length") or 0) else {} + devices = resolve_devices(body.get("devices")) + for a in devices: + state_of(a)["status"] = "Running" + push_log("ok", f"testbed started · {len(devices)} device(s)") + return {"result": "ok", "devices": devices} + + +@app.post("/stop") +async def stop(request: Request): + body = await request.json() if int(request.headers.get("content-length") or 0) else {} + devices = resolve_devices(body.get("devices")) + push_log("warn", f"stopping · {len(devices)} device(s)") + asyncio.create_task(transition(devices, "Stopping", "Bootloader", 1.2)) + return {"result": "ok", "devices": devices} + + +@app.post("/reset") +async def reset(request: Request): + body = await request.json() if int(request.headers.get("content-length") or 0) else {} + devices = resolve_devices(body.get("devices") or list(body.get("locations", {}) or {})) + push_log("warn", f"resetting · {len(devices)} device(s)") + asyncio.create_task(transition(devices, "Resetting", "Bootloader", 1.6)) + return {"result": "ok", "devices": devices} + + +async def flash_events(devices: list, fw_len: int): + for a in devices: + st = state_of(a) + st["status"] = "Programming" + st["progress"] = {"acked": 0, "total": TOTAL_CHUNKS} + push_log("info", f"flash started · {len(devices)} device(s) · {fw_len} bytes") + yield _sse( + { + "type": "flash_started", + "image_size": fw_len, + "total_chunks": TOTAL_CHUNKS, + "fw_hash": "F4KEF4KE", + "devices": sorted(devices), + } + ) + done: set = set() + while len(done) < len(devices): + await asyncio.sleep(TICK_S) + for a in devices: + if a in done: + continue + p = state_of(a)["progress"] + p["acked"] = min(p["total"], p["acked"] + CHUNKS_PER_TICK) + yield _sse({"type": "chunk", "addr": a, "acked": p["acked"], "total": p["total"]}) + if p["acked"] >= p["total"]: + done.add(a) + st = state_of(a) + st["status"] = "Bootloader" # flashed image sits ready; /start runs it + st["progress"] = None + push_log("ok", f"{a[-4:].upper()} flashed · {p['total']} chunks") + yield _sse( + { + "type": "device_done", + "addr": a, + "success": True, + "retries": 0, + "chunks_acked": p["total"], + "chunks_total": p["total"], + } + ) + push_log("ok", "flash complete · all devices") + yield _sse({"type": "complete", "all_success": True, "elapsed_s": TOTAL_CHUNKS / CHUNKS_PER_TICK * TICK_S}) + + +@app.post("/flash/stream") +async def flash_stream(request: Request): + body = await request.json() + devices = resolve_devices(body.get("devices")) + fw_len = len(body.get("firmware_b64", "")) * 3 // 4 + return StreamingResponse(flash_events(devices, fw_len), media_type="text/event-stream") + + +@app.post("/flash") +async def flash(request: Request): + body = await request.json() + devices = resolve_devices(body.get("devices")) + fw_len = len(body.get("firmware_b64", "")) * 3 // 4 + async for _ in flash_events(devices, fw_len): + pass + return {"result": "ok", "devices": devices} + + +@app.get("/events") +async def sse_events(request: Request): + async def gen(): + last_id = max(0, _event_seq - 50) # replay recent history on connect + last_snapshot = 0.0 + while True: + if await request.is_disconnected(): + return + for ev in events: + if ev["id"] > last_id: + yield _sse(ev) + last_id = ev["id"] + now = time.time() + if now - last_snapshot > 2.0: + last_snapshot = now + yield _sse({"type": "status", "response": status()["response"]}) + await asyncio.sleep(0.3) + + return StreamingResponse(gen(), media_type="text/event-stream") + + if __name__ == "__main__": parser = argparse.ArgumentParser() parser.add_argument("--controller", default="http://localhost:8000") From 418c7695b9ae67445ae5eb804228e0ff0e412351 Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Thu, 9 Jul 2026 15:34:04 +0200 Subject: [PATCH 10/27] dotbot/console-web: orchestration write path (flash dialog, SSE progress, console) AI-assisted: Claude Fable 5 --- dotbot/console-web/src/App.tsx | 23 ++++ dotbot/console-web/src/FlashDialog.tsx | 108 ++++++++++++++++++ dotbot/console-web/src/Footer.tsx | 19 ++++ dotbot/console-web/src/TestbedRail.tsx | 111 +++++++++++++++--- dotbot/console-web/src/api.ts | 66 +++++++++++ dotbot/console-web/src/useOrchestration.ts | 126 +++++++++++++++++++++ 6 files changed, 435 insertions(+), 18 deletions(-) create mode 100644 dotbot/console-web/src/FlashDialog.tsx create mode 100644 dotbot/console-web/src/useOrchestration.ts diff --git a/dotbot/console-web/src/App.tsx b/dotbot/console-web/src/App.tsx index a142452f..716aae88 100644 --- a/dotbot/console-web/src/App.tsx +++ b/dotbot/console-web/src/App.tsx @@ -8,6 +8,8 @@ import { Camera, Layers, MapView, ViewGeom } from "./MapView"; import { DoneMission, TestbedRail } from "./TestbedRail"; import { LH2Position, PlannedMission } from "./types"; import { useFleet } from "./useFleet"; +import { useOrchestration } from "./useOrchestration"; +import { FlashDialog } from "./FlashDialog"; const WAYPOINT_THRESHOLD = 60; // mm, arrival radius sent with waypoint missions @@ -31,6 +33,9 @@ export const App: React.FC = () => { toastTimer.current = window.setTimeout(() => setToast(null), 2500); }, []); + const orch = useOrchestration(showToast); + const [flashOpen, setFlashOpen] = useState(false); + // ?sel=[,] preselects bots (handy for dev/screenshots). const [selection, setSelection] = useState>(new Set()); const preselRef = useRef(false); @@ -274,6 +279,15 @@ export const App: React.FC = () => { selection={selection} planned={planned} doneMissions={doneMissions} + logs={orch.logs} + jobs={orch.jobs} + fleetPct={orch.fleetPct} + flashing={orch.flashing} + clearLogs={orch.clearLogs} + onFlashOpen={() => setFlashOpen(true)} + onStart={() => orch.act("start", selection.size ? [...selection] : undefined)} + onStop={() => orch.act("stop", selection.size ? [...selection] : undefined)} + onReset={() => orch.act("reset", selection.size ? [...selection] : undefined)} onSelectIds={(ids) => onSelect(ids, false)} onGoMission={onGoMission} onDiscardMission={onDiscardMission} @@ -442,8 +456,17 @@ export const App: React.FC = () => {
+ setFlashOpen(false)} + onFlash={(fw) => orch.flash(fw, selection.size ? [...selection] : undefined)} + /> +
void; + onFlash: (firmware: string) => void; +} + +export const FlashDialog: React.FC = (props) => { + const [fw, setFw] = useState(FIRMWARES[0]); + if (!props.open) return null; + return ( +
+
e.stopPropagation()} + style={{ + width: 400, + background: "var(--surface)", + border: "1px solid var(--hairline)", + borderRadius: 14, + padding: 22, + boxShadow: "0 20px 60px rgba(0,0,0,.5)", + }} + > +
Flash firmware
+
+ Over-the-air flash to {props.targetCount} device(s) ·{" "} + {props.targetLabel} +
+
+ Firmware image +
+ +
+
+ Cancel +
+
{ + props.onFlash(fw); + props.onClose(); + }} + style={{ + padding: "9px 18px", + borderRadius: 8, + cursor: "pointer", + fontSize: 13, + fontWeight: 600, + background: "var(--accent)", + color: "#fff", + }} + > + Flash {props.targetCount} device(s) +
+
+
+
+ ); +}; diff --git a/dotbot/console-web/src/Footer.tsx b/dotbot/console-web/src/Footer.tsx index 716ae846..9fea5558 100644 --- a/dotbot/console-web/src/Footer.tsx +++ b/dotbot/console-web/src/Footer.tsx @@ -5,6 +5,7 @@ import { Pad } from "./Joystick"; import { Camera, ViewGeom } from "./MapView"; import { Minimap } from "./Minimap"; import { LH2Position, MapSize, STATE_ORDER, UnifiedBot } from "./types"; +import { FlashJob } from "./useOrchestration"; // v1 swatch palette. const SWATCHES: [number, number, number][] = [ @@ -34,6 +35,7 @@ const short = (id: string) => id.slice(-4).toUpperCase(); interface FooterProps { bots: UnifiedBot[]; + flashQueue: Record; mapSize: MapSize; selection: Set; pendingWaypoints: LH2Position[]; @@ -417,6 +419,23 @@ export const Footer: React.FC = (props) => { {one.state}
+ {props.flashQueue[one.id] && !props.flashQueue[one.id].done && ( +
+
+ {props.flashQueue[one.id].acked} / {props.flashQueue[one.id].total} chunks +
+
+
+
+
+ )}
diff --git a/dotbot/console-web/src/TestbedRail.tsx b/dotbot/console-web/src/TestbedRail.tsx index 67abca5f..029f2843 100644 --- a/dotbot/console-web/src/TestbedRail.tsx +++ b/dotbot/console-web/src/TestbedRail.tsx @@ -1,6 +1,7 @@ import React, { useState } from "react"; import { PlannedMission, UnifiedBot } from "./types"; +import { FlashJob, LogRow } from "./useOrchestration"; // Left testbed rail, per v1: collapsed 52px icon strip <-> 340px panel with a // Testbed tab (orchestration controls - disabled until the swarmit write path @@ -28,6 +29,15 @@ interface TestbedRailProps { selection: Set; planned: PlannedMission[]; doneMissions: DoneMission[]; + logs: LogRow[]; + jobs: FlashJob[]; + fleetPct: number; + flashing: boolean; + clearLogs: () => void; + onFlashOpen: () => void; + onStart: () => void; + onStop: () => void; + onReset: () => void; onSelectIds: (ids: string[]) => void; onGoMission: (key: string) => void; onDiscardMission: (key: string) => void; @@ -55,8 +65,7 @@ const railBtn = (accent: boolean): React.CSSProperties => ({ color: accent ? "#fff" : "var(--text)", width: "100%", boxSizing: "border-box", - cursor: "not-allowed", - opacity: 0.55, + cursor: "pointer", }); const tabStyle = (active: boolean): React.CSSProperties => ({ @@ -139,7 +148,6 @@ export const TestbedRail: React.FC = (props) => { const missions = deriveMissions(props.bots, props.planned); const targetLabel = props.selection.size ? `${props.selection.size} selected` : "whole fleet"; - const orchTitle = "Arrives with orchestration (swarmit write path is read-only for now)"; const ico: React.CSSProperties = { width: 32, @@ -175,12 +183,12 @@ export const TestbedRail: React.FC = (props) => {
{[ - { g: "⇩", t: `Flash - ${orchTitle}` }, - { g: "▶", t: `Start - ${orchTitle}` }, - { g: "■", t: `Stop - ${orchTitle}` }, - { g: "↻", t: `Reset - ${orchTitle}` }, + { g: "⇩", t: "Flash…", fn: props.onFlashOpen }, + { g: "▶", t: "Start", fn: props.onStart }, + { g: "■", t: "Stop", fn: props.onStop }, + { g: "↻", t: "Reset", fn: props.onReset }, ].map((x, i) => ( -
+
{x.g}
))} @@ -283,19 +291,30 @@ export const TestbedRail: React.FC = (props) => { Target · {targetLabel}
-
+
⇩ Flash…
-
+
▶ Start
-
+
■ Stop
-
+
↻ Reset
+ {props.flashing && ( +
+
+ Flashing + {props.fleetPct}% +
+
+
+
+
+ )}
setTab("console")} style={tabStyle(tab === "console")}> @@ -311,19 +330,75 @@ export const TestbedRail: React.FC = (props) => { /events · log_event +
+ + Clear +
-
-
- Live testbed logs arrive with the orchestration iteration (swarmit /events SSE). +
+
+ {props.logs.length === 0 && ( +
No log events yet.
+ )} + {props.logs.map((l) => ( +
+ {l.t} + {l.msg} +
+ ))}
)} {tab === "flash" && (
-
- No active flash. Pick targets in any view (or none = whole fleet), then Flash… -
+ {props.jobs.length === 0 ? ( +
+ No active flash. Pick targets in any view (or none = whole fleet), then Flash… +
+ ) : ( + props.jobs.map((j) => { + const pct = j.total ? Math.round((j.acked / j.total) * 100) : 0; + return ( +
+
+ {short(j.addr)} + + {j.done ? "done" : `${pct}%`} + +
+
+
+
+
+ ); + }) + )}
)}
diff --git a/dotbot/console-web/src/api.ts b/dotbot/console-web/src/api.ts index 34ced4f0..e4ea4ef7 100644 --- a/dotbot/console-web/src/api.ts +++ b/dotbot/console-web/src/api.ts @@ -62,3 +62,69 @@ export function controllerWsUrl(): string { const proto = window.location.protocol === "https:" ? "wss" : "ws"; return `${proto}://${window.location.host}${CONTROLLER}/ws/status`; } + +// --- SwarmIT orchestration (write path; same contract as the real server) --- + +export async function swarmitAction( + action: "start" | "stop" | "reset", + devices?: string[], +): Promise { + await fetch(`${SWARMIT}/${action}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(devices && devices.length ? { devices } : {}), + }); +} + +export interface FlashEvent { + type: "flash_started" | "chunk" | "device_done" | "complete" | "error"; + addr?: string; + acked?: number; + total?: number; + devices?: string[]; + total_chunks?: number; + success?: boolean; + all_success?: boolean; + message?: string; +} + +// POST /flash/stream and feed each SSE event to the callback. +export async function flashStream( + firmwareB64: string, + devices: string[] | undefined, + onEvent: (ev: FlashEvent) => void, +): Promise { + const res = await fetch(`${SWARMIT}/flash/stream`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + firmware_b64: firmwareB64, + ...(devices && devices.length ? { devices } : {}), + }), + }); + const reader = res.body?.getReader(); + if (!reader) return; + const decoder = new TextDecoder(); + let buf = ""; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + buf += decoder.decode(value, { stream: true }); + let idx; + while ((idx = buf.indexOf("\n\n")) >= 0) { + const frame = buf.slice(0, idx); + buf = buf.slice(idx + 2); + const line = frame.split("\n").find((l) => l.startsWith("data: ")); + if (!line) continue; + try { + onEvent(JSON.parse(line.slice(6))); + } catch { + /* skip malformed frame */ + } + } + } +} + +export function swarmitEventsUrl(): string { + return `${SWARMIT}/events`; +} diff --git a/dotbot/console-web/src/useOrchestration.ts b/dotbot/console-web/src/useOrchestration.ts new file mode 100644 index 00000000..40172d72 --- /dev/null +++ b/dotbot/console-web/src/useOrchestration.ts @@ -0,0 +1,126 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +import { flashStream, swarmitAction, swarmitEventsUrl } from "./api"; + +export interface LogRow { + key: string; + t: string; // HH:MM:SS + level: "info" | "ok" | "warn" | "err"; + msg: string; +} + +export interface FlashJob { + addr: string; + acked: number; + total: number; + done: boolean; + success?: boolean; +} + +// Orchestration plane: live log feed (swarmit /events SSE) + flash queue +// (driven by /flash/stream chunk events) + the start/stop/reset actions. +export function useOrchestration(onToast: (msg: string) => void) { + const [logs, setLogs] = useState([]); + const [queue, setQueue] = useState>({}); + const [flashing, setFlashing] = useState(false); + + // Log feed. + useEffect(() => { + let es: EventSource | null = null; + let closed = false; + const connect = () => { + es = new EventSource(swarmitEventsUrl()); + es.onmessage = (e) => { + try { + const ev = JSON.parse(e.data); + if (ev.type !== "log_event") return; + const t = new Date((ev.ts ?? Date.now() / 1000) * 1000).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + setLogs((prev) => + [...prev, { key: `${ev.id}`, t, level: ev.level ?? "info", msg: ev.message ?? "" }].slice(-200), + ); + } catch { + /* ignore */ + } + }; + es.onerror = () => { + es?.close(); + if (!closed) setTimeout(connect, 2000); + }; + }; + connect(); + return () => { + closed = true; + es?.close(); + }; + }, []); + + const clearLogs = useCallback(() => setLogs([]), []); + + const act = useCallback( + (action: "start" | "stop" | "reset", devices?: string[]) => { + const target = devices && devices.length ? `${devices.length} device(s)` : "whole fleet"; + swarmitAction(action, devices) + .then(() => onToast(`${action[0].toUpperCase()}${action.slice(1)} sent · ${target}`)) + .catch(() => onToast(`${action} failed`)); + }, + [onToast], + ); + + const flashingRef = useRef(false); + const flash = useCallback( + (firmwareName: string, devices?: string[]) => { + if (flashingRef.current) { + onToast("A flash is already in progress"); + return; + } + flashingRef.current = true; + setFlashing(true); + setQueue({}); + // The fake accepts any payload; against a real server this is the + // base64 of the picked image file. + const b64 = btoa(firmwareName); + flashStream(b64, devices, (ev) => { + if (ev.type === "flash_started" && ev.devices) { + setQueue( + Object.fromEntries( + ev.devices.map((a) => [a, { addr: a, acked: 0, total: ev.total_chunks ?? 0, done: false }]), + ), + ); + } else if (ev.type === "chunk" && ev.addr) { + setQueue((q) => ({ + ...q, + [ev.addr!]: { ...q[ev.addr!], acked: ev.acked ?? 0, total: ev.total ?? 0 }, + })); + } else if (ev.type === "device_done" && ev.addr) { + setQueue((q) => ({ + ...q, + [ev.addr!]: { ...q[ev.addr!], done: true, success: ev.success }, + })); + } else if (ev.type === "complete") { + onToast(ev.all_success ? "Flash complete" : "Flash finished with failures"); + } else if (ev.type === "error") { + onToast(`Flash error: ${ev.message ?? "unknown"}`); + } + }) + .catch(() => onToast("Flash stream failed")) + .finally(() => { + flashingRef.current = false; + setFlashing(false); + // Keep the last queue visible briefly, then clear. + setTimeout(() => setQueue({}), 4000); + }); + }, + [onToast], + ); + + const jobs = Object.values(queue); + const fleetPct = jobs.length + ? Math.round((jobs.reduce((a, j) => a + j.acked, 0) / Math.max(1, jobs.reduce((a, j) => a + j.total, 0))) * 100) + : 0; + + return { logs, clearLogs, queue, jobs, flashing, fleetPct, act, flash }; +} From 3cb131f790da8665f4ca7163b0e8f624b4144d61 Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Thu, 9 Jul 2026 16:05:21 +0200 Subject: [PATCH 11/27] dotbot/console-web: track canvas size live so the arena keeps its margins AI-assisted: Claude Fable 5 --- dotbot/console-web/src/MapView.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/dotbot/console-web/src/MapView.tsx b/dotbot/console-web/src/MapView.tsx index 062f5aa8..89b3e956 100644 --- a/dotbot/console-web/src/MapView.tsx +++ b/dotbot/console-web/src/MapView.tsx @@ -75,15 +75,25 @@ export const MapView: React.FC = (props) => { const [side, setSide] = useState(600); const onGeomRef = useRef(props.onGeom); onGeomRef.current = props.onGeom; + // Track the canvas size live (rail open/close, window resize): the arena + // keeps its margins instead of overflowing when the canvas shrinks. const measure = useCallback((el: HTMLDivElement | null) => { (wrapRef as React.MutableRefObject).current = el; - if (el) { + }, []); + React.useEffect(() => { + const el = wrapRef.current; + if (!el) return; + const update = () => { const r = el.getBoundingClientRect(); const s = Math.max(200, Math.min(r.width, r.height) - 48); setSide(s); geomRef.current = { w: r.width, h: r.height, side: s }; onGeomRef.current(geomRef.current); - } + }; + update(); + const ro = new ResizeObserver(update); + ro.observe(el); + return () => ro.disconnect(); }, []); // v1 coordinate convention: y grows NORTH (up); screen top = arena max-y. From 245c80f382c4a4d2551f7c69688a5f74c4654951 Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Thu, 9 Jul 2026 20:12:32 +0200 Subject: [PATCH 12/27] dotbot/console-web: theme dev param + puppeteer screenshot helper AI-assisted: Claude Fable 5 --- dotbot/console-web/dev/screenshot.mjs | 23 + dotbot/console-web/package-lock.json | 986 ++++++++++++++++++++++++++ dotbot/console-web/package.json | 1 + dotbot/console-web/src/App.tsx | 5 +- 4 files changed, 1014 insertions(+), 1 deletion(-) create mode 100644 dotbot/console-web/dev/screenshot.mjs diff --git a/dotbot/console-web/dev/screenshot.mjs b/dotbot/console-web/dev/screenshot.mjs new file mode 100644 index 00000000..ae987aec --- /dev/null +++ b/dotbot/console-web/dev/screenshot.mjs @@ -0,0 +1,23 @@ +// Dev screenshot helper: loads a console URL in headless Chrome, waits for +// live data to render (or a fixed delay), then captures a PNG. +// Usage: node dev/screenshot.mjs [waitMs=4000] +import puppeteer from "puppeteer-core"; + +const [url, outfile, waitMs = "4000"] = process.argv.slice(2); +if (!url || !outfile) { + console.error("usage: node dev/screenshot.mjs [waitMs]"); + process.exit(1); +} + +const browser = await puppeteer.launch({ + executablePath: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + headless: "shell", + args: ["--disable-gpu", "--hide-scrollbars"], +}); +const page = await browser.newPage(); +await page.setViewport({ width: 1440, height: 900 }); +await page.goto(url, { waitUntil: "domcontentloaded" }); +await new Promise((r) => setTimeout(r, Number(waitMs))); +await page.screenshot({ path: outfile }); +await browser.close(); +console.log("saved", outfile); diff --git a/dotbot/console-web/package-lock.json b/dotbot/console-web/package-lock.json index c72837fe..abdfa86a 100644 --- a/dotbot/console-web/package-lock.json +++ b/dotbot/console-web/package-lock.json @@ -15,6 +15,7 @@ "@types/react": "^18.2.43", "@types/react-dom": "^18.2.17", "@vitejs/plugin-react": "^4.2.1", + "puppeteer-core": "^24.43.1", "typescript": "^5.2.2", "vite": "^5.0.8" } @@ -742,6 +743,41 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@puppeteer/browsers": { + "version": "2.13.2", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.13.2.tgz", + "integrity": "sha512-5EUZSUIc37H6aIXyWO0Z4y8NlF8NnjgmqeQgOGiswAU7pY0HOo16ho4+alIWmSfdZnjqBRawMsP3I5YqLSn6kw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.4.3", + "extract-zip": "^2.0.1", + "progress": "^2.0.3", + "proxy-agent": "^6.5.0", + "semver": "^7.7.4", + "tar-fs": "^3.1.1", + "yargs": "^17.7.2" + }, + "bin": { + "browsers": "lib/cjs/main-cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@puppeteer/browsers/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -1099,6 +1135,13 @@ "win32" ] }, + "node_modules/@tootallnate/quickjs-emscripten": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", + "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1151,6 +1194,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "26.1.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "undici-types": "~8.3.0" + } + }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", @@ -1179,6 +1233,17 @@ "@types/react": "^18.0.0" } }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -1200,6 +1265,155 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "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/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ast-types": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", + "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/bare-events": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.1.tgz", + "integrity": "sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.7.4", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.4.tgz", + "integrity": "sha512-y1kC+ffIx/tPLdTE693uNjHfzTfr+ravR5tvWlMXe25nELbkqV400S71qHDwbkAQ1FVEZobB1NFRzFbCCcyBCQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.1.1.tgz", + "integrity": "sha512-JprUlveX3QjApC1cTpsUOiscADftCGVWkzitbHsRqv84hzYwYHw2mbluddsq5TvI8mH/8Ov1f4BiMAdcB0oYnQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/bare-stream": { + "version": "2.13.3", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.3.tgz", + "integrity": "sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.8.1", + "streamx": "^2.25.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-abort-controller": "*", + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-stream/node_modules/b4a": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", + "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.5.tgz", + "integrity": "sha512-K+y9xF1tN+CdPu4qWwr0QiK1Al07eFPGYK5M2pDXcmHdMdgC/tT/bpmMe1hrmRHaidKLkXrC+cRNYf3XVDUhSQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-path": "^3.0.0" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.10.42", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.42.tgz", @@ -1213,6 +1427,16 @@ "node": ">=6.0.0" } }, + "node_modules/basic-ftp": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.1.tgz", + "integrity": "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/browserslist": { "version": "4.28.5", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.5.tgz", @@ -1247,6 +1471,16 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001803", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001803.tgz", @@ -1268,6 +1502,55 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chromium-bidi": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-14.0.0.tgz", + "integrity": "sha512-9gYlLtS6tStdRWzrtXaTMnqcM4dudNegMXJxkR0I/CXObHalYeYcAMPrL19eroNZHtJ8DQmu1E+ZNOYu/IXMXw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "mitt": "^3.0.1", + "zod": "^3.24.1" + }, + "peerDependencies": { + "devtools-protocol": "*" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1282,6 +1565,16 @@ "dev": true, "license": "MIT" }, + "node_modules/data-uri-to-buffer": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", + "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1300,6 +1593,28 @@ } } }, + "node_modules/degenerator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", + "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ast-types": "^0.13.4", + "escodegen": "^2.1.0", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/devtools-protocol": { + "version": "0.0.1608973", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1608973.tgz", + "integrity": "sha512-Tpm17fxYzt+J7VrGdc1k8YdRqS3YV7se/M6KeemEqvUbq/n7At1rWVuXMxQgpWkdwSdIEKYbU//Bve+Shm4YNQ==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/electron-to-chromium": { "version": "1.5.389", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.389.tgz", @@ -1307,6 +1622,23 @@ "dev": true, "license": "ISC" }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -1356,6 +1688,110 @@ "node": ">=6" } }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1381,6 +1817,95 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-uri": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", + "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "basic-ftp": "^5.0.2", + "data-uri-to-buffer": "^6.0.2", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "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/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -1435,6 +1960,13 @@ "yallist": "^3.0.2" } }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "dev": true, + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1461,6 +1993,16 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/netmask": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.1.1.tgz", + "integrity": "sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/node-releases": { "version": "2.0.51", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", @@ -1471,6 +2013,57 @@ "node": ">=18" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/pac-proxy-agent": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", + "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tootallnate/quickjs-emscripten": "^0.23.0", + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "get-uri": "^6.0.1", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.6", + "pac-resolver": "^7.0.1", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pac-resolver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", + "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", + "dev": true, + "license": "MIT", + "dependencies": { + "degenerator": "^5.0.0", + "netmask": "^2.0.2" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1507,6 +2100,83 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/proxy-agent": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", + "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "http-proxy-agent": "^7.0.1", + "https-proxy-agent": "^7.0.6", + "lru-cache": "^7.14.1", + "pac-proxy-agent": "^7.1.0", + "proxy-from-env": "^1.1.0", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/proxy-agent/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "dev": true, + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/puppeteer-core": { + "version": "24.43.1", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.43.1.tgz", + "integrity": "sha512-T5ScUMAsmhdNbgDR41AGESYeS6V9MSgetkSnVhhW+gXvzC42VesKCn5ld87gAZDJ6vLHL9GkRvY9WtQWSnwFbw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "2.13.2", + "chromium-bidi": "14.0.0", + "debug": "^4.4.3", + "devtools-protocol": "0.0.1608973", + "typed-query-selector": "^2.12.2", + "webdriver-bidi-protocol": "0.4.1", + "ws": "^8.20.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -1542,6 +2212,16 @@ "node": ">=0.10.0" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rollup": { "version": "4.62.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", @@ -1606,6 +2286,58 @@ "semver": "bin/semver.js" } }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", + "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1616,6 +2348,138 @@ "node": ">=0.10.0" } }, + "node_modules/streamx": { + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.0.tgz", + "integrity": "sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==", + "dev": true, + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar-fs": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.3.tgz", + "integrity": "sha512-/hU4AXnIdZu+Gvl1pk0oI5f5HxWsCJRtY2aFaJdk9VvyL48DWU6iU5WAIPG+wIi1YvWA6eTJvIviP/tMAZZNwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" + } + }, + "node_modules/tar-stream": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz", + "integrity": "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "bare-fs": "^4.5.5", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/tar-stream/node_modules/b4a": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", + "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, + "node_modules/text-decoder/node_modules/b4a": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", + "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, + "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" + }, + "node_modules/typed-query-selector": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.2.tgz", + "integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==", + "dev": true, + "license": "MIT" + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -1630,6 +2494,14 @@ "node": ">=14.17" } }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -1721,12 +2593,126 @@ } } }, + "node_modules/webdriver-bidi-protocol": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.1.tgz", + "integrity": "sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "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/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "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/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/dotbot/console-web/package.json b/dotbot/console-web/package.json index ad0cb6c6..4924bc6a 100644 --- a/dotbot/console-web/package.json +++ b/dotbot/console-web/package.json @@ -16,6 +16,7 @@ "@types/react": "^18.2.43", "@types/react-dom": "^18.2.17", "@vitejs/plugin-react": "^4.2.1", + "puppeteer-core": "^24.43.1", "typescript": "^5.2.2", "vite": "^5.0.8" } diff --git a/dotbot/console-web/src/App.tsx b/dotbot/console-web/src/App.tsx index 716aae88..e7022cc1 100644 --- a/dotbot/console-web/src/App.tsx +++ b/dotbot/console-web/src/App.tsx @@ -17,7 +17,10 @@ type ViewKind = "map" | "list" | "grid"; export const App: React.FC = () => { const { bots, mapSize, wsUp } = useFleet(); - const [theme, setTheme] = useState<"dark" | "light">("dark"); + // ?theme=dark|light presets the theme (handy for dev/screenshots). + const [theme, setTheme] = useState<"dark" | "light">(() => + new URLSearchParams(window.location.search).get("theme") === "light" ? "light" : "dark", + ); // ?view=map|list|grid opens a specific view (handy for dev/screenshots). const [view, setView] = useState(() => { const v = new URLSearchParams(window.location.search).get("view"); From 330a08ad718238c21e1fddae468af5eea0b217b4 Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Thu, 9 Jul 2026 20:21:22 +0200 Subject: [PATCH 13/27] dotbot/console-web: manual test guide AI-assisted: Claude Fable 5 --- dotbot/console-web/dev/TESTING.md | 81 +++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 dotbot/console-web/dev/TESTING.md diff --git a/dotbot/console-web/dev/TESTING.md b/dotbot/console-web/dev/TESTING.md new file mode 100644 index 00000000..18814f0e --- /dev/null +++ b/dotbot/console-web/dev/TESTING.md @@ -0,0 +1,81 @@ +# Console manual test guide + +Start the stack (three terminals, from the workspace root, venv active): + +```bash +PYTHONPATH=repos/wt-PyDotBot-unified-web-ui dotbot run simulator --headless \ + --simulator-init-state repos/wt-PyDotBot-unified-web-ui/dotbot/console-web/dev/simulator_init_state.toml + +python repos/wt-PyDotBot-unified-web-ui/dotbot/console-web/dev/fake_swarmit_server.py + +npm --prefix repos/wt-PyDotBot-unified-web-ui/dotbot/console-web run start +``` + +Open http://localhost:5173. Handy URL params: `?sel=1111` (preselect), +`?view=list|grid`, `?rail=testbed|missions`, `?theme=light`. + +If the title bar says OFFLINE: the vite proxy targets do not match where the +controller runs. `curl localhost:5173/controller/dotbots` must return bots. + +## Checklist + +Map + selection +- [ ] Title bar: LIVE (green pulse), bot count; Dark|Light toggle flips theme +- [ ] Bots move smoothly; heading pointer at the circle edge; battery bar above +- [ ] Plain drag pans (clamped); plain click clears selection +- [ ] Shift-drag marquee selects; shift-click toggles one bot +- [ ] Click bot: red rectangle + id chip; hover another bot: chip appears +- [ ] Zoom +/-/recenter; arena keeps margins when rail opens or window resizes +- [ ] Layers panel: Battery Bars / Waypoints / DotBots / Real-scale / Trails toggle live + +Footer (bottom strip) +- [ ] Nothing selected: N/1000 + per-state rollup; click a state row selects those bots +- [ ] One bot: LED thumb, short id, device, Drivable pill, state, battery, "x, y mm" +- [ ] Ghost bot (0001/0002): "Not drivable" pill, dock grayed, warning hint +- [ ] Multi: "N selected", per-state mini-rollup, dock drives the group +- [ ] Minimap: square (arena aspect), dots colored by state, drag moves the map view + +Control dock +- [ ] Joystick: drag = bot drives, release = stops; knob shows LED color + live heading +- [ ] LED button -> swatch grid -> bot circle + map dot recolor (toast confirms) +- [ ] Alt-click map queues waypoints (dashed diamonds); popover lists "x, y mm" with per-item remove +- [ ] Go sends: bot navigates, diamonds go solid, button morphs to "Stop nav" +- [ ] Deselect and reselect other bots: the Planned mission survives (see rail Missions) + +Testbed rail +- [ ] Icon strip -> panel; Testbed tab: Target label follows selection +- [ ] Flash... dialog: pick image, Flash N device(s); bots blink amber, queue tab + shows per-device % bars, fleet % bar in Testbed tab, footer shows chunk progress +- [ ] After flash: bots in Bootloader (not drivable) -> Start returns them to Running +- [ ] Stop: Stopping -> Bootloader; Reset: Resetting -> Bootloader +- [ ] Console tab: log lines stream (flash/start/stop events), Clear works +- [ ] Missions tab: Planned (Go / discard) and Active (interrupt) rows; click row + reselects its bots; arrival adds a "Recently completed" line + +List / Grid +- [ ] Search by id, state filter, column sort; checkbox multi-select + select-all +- [ ] Selection carries across Map/List/Grid and drives the same footer dock + +## Scripted actions (against the running stack) + +```bash +# color a bot's LED +curl -X PUT localhost:8000/controller/dotbots/badcafe111111111/0/rgb_led \ + -H 'Content-Type: application/json' -d '{"red":34,"green":197,"blue":94}' + +# send a waypoint mission (bot navigates on its own) +curl -X PUT localhost:8000/controller/dotbots/badcafe111111111/0/waypoints \ + -H 'Content-Type: application/json' \ + -d '{"threshold":60,"waypoints":[{"x":400,"y":1600},{"x":1600,"y":400}]}' + +# flash two bots (watch rail queue + console) +curl -N -X POST localhost:8001/flash/stream -H 'Content-Type: application/json' \ + -d '{"firmware_b64":"ZmFrZQ==","devices":["badcafe111111111","deadbeef22222222"]}' + +# stop / start / reset a subset (or omit devices = whole fleet) +curl -X POST localhost:8001/stop -H 'Content-Type: application/json' \ + -d '{"devices":["badcafe111111111"]}' + +# screenshot for the Claude Design re-seed loop +node dotbot/console-web/dev/screenshot.mjs "http://127.0.0.1:5173/?sel=1111" out.png +``` From e47978dfcfd8ca0d3967a4a70add8a829b93c131 Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Thu, 9 Jul 2026 20:30:03 +0200 Subject: [PATCH 14/27] dotbot/console-web: file-manager selection in list/grid (shift range, cmd toggle) AI-assisted: Claude Fable 5 --- dotbot/console-web/src/App.tsx | 12 +++++---- dotbot/console-web/src/GridView.tsx | 27 +++++++++++++++++--- dotbot/console-web/src/ListView.tsx | 38 +++++++++++++++++++++-------- 3 files changed, 59 insertions(+), 18 deletions(-) diff --git a/dotbot/console-web/src/App.tsx b/dotbot/console-web/src/App.tsx index e7022cc1..2c386b79 100644 --- a/dotbot/console-web/src/App.tsx +++ b/dotbot/console-web/src/App.tsx @@ -65,11 +65,13 @@ export const App: React.FC = () => { trails: false, }); - const onSelect = useCallback((ids: string[], additive: boolean) => { + // replace = set selection to ids · toggle = flip each id · add = union (range select) + const onSelect = useCallback((ids: string[], mode: "replace" | "toggle" | "add") => { setSelection((prev) => { - if (!additive) return new Set(ids); + if (mode === "replace") return new Set(ids); const next = new Set(prev); - ids.forEach((id) => (next.has(id) ? next.delete(id) : next.add(id))); + if (mode === "add") ids.forEach((id) => next.add(id)); + else ids.forEach((id) => (next.has(id) ? next.delete(id) : next.add(id))); return next; }); }, []); @@ -291,7 +293,7 @@ export const App: React.FC = () => { onStart={() => orch.act("start", selection.size ? [...selection] : undefined)} onStop={() => orch.act("stop", selection.size ? [...selection] : undefined)} onReset={() => orch.act("reset", selection.size ? [...selection] : undefined)} - onSelectIds={(ids) => onSelect(ids, false)} + onSelectIds={(ids) => onSelect(ids, "replace")} onGoMission={onGoMission} onDiscardMission={onDiscardMission} onStopMission={onStopMission} @@ -476,7 +478,7 @@ export const App: React.FC = () => { cam={cam} setCam={setCam} geom={geom} - onSelectState={(ids) => onSelect(ids, false)} + onSelectState={(ids) => onSelect(ids, "replace")} onGo={onGo} onStopNav={onStopNav} onClearQueue={onClearQueue} diff --git a/dotbot/console-web/src/GridView.tsx b/dotbot/console-web/src/GridView.tsx index 494cfe43..c165f36e 100644 --- a/dotbot/console-web/src/GridView.tsx +++ b/dotbot/console-web/src/GridView.tsx @@ -6,12 +6,33 @@ import { BatteryCell, FilterBar, LedDot, Pagination, useQueriedBots, useViewQuer interface GridViewProps { bots: UnifiedBot[]; selection: Set; - onSelect: (ids: string[], additive: boolean) => void; + onSelect: (ids: string[], mode: "replace" | "toggle" | "add") => void; } export const GridView: React.FC = ({ bots, selection, onSelect }) => { const { q, setQ } = useViewQuery(); const { rows, total, pages } = useQueriedBots(bots, q); + // File-manager selection: click = single, shift+click = range from the + // anchor in the current card order, cmd/ctrl = toggle. + const anchorRef = React.useRef(null); + const cardClick = (e: React.MouseEvent, id: string) => { + if (e.shiftKey && anchorRef.current) { + const ids = rows.map((r) => r.id); + const a = ids.indexOf(anchorRef.current); + const b = ids.indexOf(id); + if (a >= 0 && b >= 0) { + onSelect(ids.slice(Math.min(a, b), Math.max(a, b) + 1), "add"); + return; + } + } + if (e.metaKey || e.ctrlKey) { + onSelect([id], "toggle"); + anchorRef.current = id; + return; + } + onSelect([id], "replace"); + anchorRef.current = id; + }; return (
= ({ bots, selection, onSelect }) paddingTop: 58, // clear the floating view switcher background: "var(--canvas)", }} - onClick={() => onSelect([], false)} + onClick={() => onSelect([], "replace")} >
@@ -44,7 +65,7 @@ export const GridView: React.FC = ({ bots, selection, onSelect }) key={b.id} onClick={(e) => { e.stopPropagation(); - onSelect([b.id], e.shiftKey); + cardClick(e, b.id); }} style={{ display: "flex", diff --git a/dotbot/console-web/src/ListView.tsx b/dotbot/console-web/src/ListView.tsx index 370ad3e6..27ec1a45 100644 --- a/dotbot/console-web/src/ListView.tsx +++ b/dotbot/console-web/src/ListView.tsx @@ -14,12 +14,33 @@ import { interface ListViewProps { bots: UnifiedBot[]; selection: Set; - onSelect: (ids: string[], additive: boolean) => void; + onSelect: (ids: string[], mode: "replace" | "toggle" | "add") => void; } export const ListView: React.FC = ({ bots, selection, onSelect }) => { const { q, setQ } = useViewQuery(); const { rows, total, pages } = useQueriedBots(bots, q); + // File-manager selection: click = single, shift+click = range from the + // anchor (last plain/cmd click) in the current row order, cmd/ctrl = toggle. + const anchorRef = React.useRef(null); + const rowClick = (e: React.MouseEvent, id: string) => { + if (e.shiftKey && anchorRef.current) { + const ids = rows.map((r) => r.id); + const a = ids.indexOf(anchorRef.current); + const b = ids.indexOf(id); + if (a >= 0 && b >= 0) { + onSelect(ids.slice(Math.min(a, b), Math.max(a, b) + 1), "add"); + return; + } + } + if (e.metaKey || e.ctrlKey) { + onSelect([id], "toggle"); + anchorRef.current = id; + return; + } + onSelect([id], "replace"); + anchorRef.current = id; + }; const sortBy = (key: SortKey) => setQ((p) => ({ @@ -31,12 +52,8 @@ export const ListView: React.FC = ({ bots, selection, onSelect }) const allVisibleSelected = rows.length > 0 && rows.every((b) => selection.has(b.id)); const toggleAll = () => { - if (allVisibleSelected) { - onSelect(rows.map((b) => b.id), true); // additive toggles them off - } else { - const missing = rows.filter((b) => !selection.has(b.id)).map((b) => b.id); - onSelect(missing, true); - } + if (allVisibleSelected) onSelect(rows.map((b) => b.id), "toggle"); // all off + else onSelect(rows.filter((b) => !selection.has(b.id)).map((b) => b.id), "add"); }; const th: React.CSSProperties = { @@ -79,7 +96,7 @@ export const ListView: React.FC = ({ bots, selection, onSelect }) paddingTop: 58, // clear the floating view switcher background: "var(--canvas)", }} - onClick={() => onSelect([], false)} + onClick={() => onSelect([], "replace")} >
= ({ bots, selection, onSelect }) return ( onSelect([b.id], false)} + onClick={(e) => rowClick(e, b.id)} style={{ cursor: "pointer", background: checked ? "rgba(228,3,46,.07)" : "transparent", @@ -131,7 +148,8 @@ export const ListView: React.FC = ({ bots, selection, onSelect })
{ e.stopPropagation(); - onSelect([b.id], true); + onSelect([b.id], "toggle"); + anchorRef.current = b.id; }} style={checkBox(checked)} > From 96df0e86078df08cff8737fd030c0a59e4f13dc9 Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Thu, 9 Jul 2026 20:30:03 +0200 Subject: [PATCH 15/27] dotbot/console-web: fix heading pointer and pad steering sense vs controller conventions The controller reports direction as 0 = north (+y), positive counter- clockwise, so the north-up display must negate it; the pad's steering sign is verified against the simulator (pad-right = clockwise arc) but the classic frontend used the opposite sign on real hardware - validate on a real DotBot before trusting teleop in the field. AI-assisted: Claude Fable 5 --- dotbot/console-web/src/Joystick.tsx | 11 ++++++++--- dotbot/console-web/src/MapView.tsx | 15 +++++++++------ 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/dotbot/console-web/src/Joystick.tsx b/dotbot/console-web/src/Joystick.tsx index 216d997e..191e99b8 100644 --- a/dotbot/console-web/src/Joystick.tsx +++ b/dotbot/console-web/src/Joystick.tsx @@ -17,8 +17,13 @@ function speeds(dx: number, dy: number): { left: number; right: number } { const py = (dy / R) * 100; const dir = (128 * py / 200) * -1; const angle = (128 * px) / 200; - let left = dir + angle; - let right = dir - angle; + // Sign verified against the simulator: right wheel faster turns the bot + // clockwise on the north-up map, so pad-right = turn right on screen. + // NOTE: the classic frontend used the opposite sign against real hardware - + // validate on a real DotBot (a mismatch here would point at a left/right + // label swap between dotbot_simulator.py and the firmware). + let left = dir - angle; + let right = dir + angle; if (left > 0) left += SPEED_OFFSET; if (left < 0) left -= SPEED_OFFSET; if (right > 0) right += SPEED_OFFSET; @@ -134,7 +139,7 @@ export const Pad: React.FC = ({ targets, disabled }) => { borderLeft: "5px solid transparent", borderRight: "5px solid transparent", borderBottom: "9px solid rgba(255,255,255,.92)", - transform: `translate(-50%, -50%) rotate(${single.heading}deg) translateY(-13px)`, + transform: `translate(-50%, -50%) rotate(${-single.heading}deg) translateY(-13px)`, }} /> )} diff --git a/dotbot/console-web/src/MapView.tsx b/dotbot/console-web/src/MapView.tsx index 89b3e956..efb754b6 100644 --- a/dotbot/console-web/src/MapView.tsx +++ b/dotbot/console-web/src/MapView.tsx @@ -48,7 +48,7 @@ interface MapViewProps { cam: Camera; setCam: React.Dispatch>; onGeom: (g: ViewGeom) => void; - onSelect: (ids: string[], additive: boolean) => void; + onSelect: (ids: string[], mode: "replace" | "toggle" | "add") => void; onAddWaypoint: (p: LH2Position) => void; } @@ -152,7 +152,7 @@ export const MapView: React.FC = (props) => { if (panRef.current) { const moved = panRef.current.moved; panRef.current = null; - if (!moved) props.onSelect([], false); // plain click on empty canvas clears + if (!moved) props.onSelect([], "replace"); // plain click on empty canvas clears return; } if (!marqueeRef.current || !marquee) { @@ -176,7 +176,7 @@ export const MapView: React.FC = (props) => { return cx >= x0 && cx <= x1 && cy >= y0 && cy <= y1; }) .map((b) => b.id); - props.onSelect(hits, true); + props.onSelect(hits, "add"); } marqueeRef.current = null; setMarquee(null); @@ -337,7 +337,7 @@ export const MapView: React.FC = (props) => { if (p) props.onAddWaypoint(p); return; } - props.onSelect([b.id], e.shiftKey || e.metaKey || e.ctrlKey); + props.onSelect([b.id], e.shiftKey || e.metaKey || e.ctrlKey ? "toggle" : "replace"); }} onPointerEnter={() => setHoverId(b.id)} onPointerLeave={() => setHoverId((h) => (h === b.id ? null : h))} @@ -401,7 +401,10 @@ export const MapView: React.FC = (props) => { animation: blink ? "dbBlink 1.1s ease-in-out infinite" : undefined, }} /> - {/* heading pointer (state-colored, at the edge) */} + {/* heading pointer (state-colored, at the edge). The + controller reports direction as 0 = north (+y), + positive COUNTERclockwise; CSS rotates clockwise in + screen coords, so the angle is negated. */} {b.heading !== null && (
= (props) => { top: "50%", width: 0, height: 0, - transform: `translate(-50%, -50%) rotate(${b.heading}deg)`, + transform: `translate(-50%, -50%) rotate(${-b.heading}deg)`, }} >
Date: Thu, 9 Jul 2026 20:40:05 +0200 Subject: [PATCH 16/27] dotbot/console-web: background click deselects; click-again on sole item deselects AI-assisted: Claude Fable 5 --- dotbot/console-web/src/GridView.tsx | 14 +++++++++-- dotbot/console-web/src/ListView.tsx | 36 +++++++++++++++++++++-------- dotbot/console-web/src/MapView.tsx | 8 ++++++- 3 files changed, 46 insertions(+), 12 deletions(-) diff --git a/dotbot/console-web/src/GridView.tsx b/dotbot/console-web/src/GridView.tsx index c165f36e..8b79ff67 100644 --- a/dotbot/console-web/src/GridView.tsx +++ b/dotbot/console-web/src/GridView.tsx @@ -30,6 +30,12 @@ export const GridView: React.FC = ({ bots, selection, onSelect }) anchorRef.current = id; return; } + // Plain click on the sole selected item deselects it. + if (selection.has(id) && selection.size === 1) { + onSelect([], "replace"); + anchorRef.current = null; + return; + } onSelect([id], "replace"); anchorRef.current = id; }; @@ -48,7 +54,9 @@ export const GridView: React.FC = ({ bots, selection, onSelect }) }} onClick={() => onSelect([], "replace")} > - +
e.stopPropagation()}> + +
= ({ bots, selection, onSelect }) })}
- +
e.stopPropagation()}> + +
); }; diff --git a/dotbot/console-web/src/ListView.tsx b/dotbot/console-web/src/ListView.tsx index 27ec1a45..2e9ae1e5 100644 --- a/dotbot/console-web/src/ListView.tsx +++ b/dotbot/console-web/src/ListView.tsx @@ -38,6 +38,12 @@ export const ListView: React.FC = ({ bots, selection, onSelect }) anchorRef.current = id; return; } + // Plain click on the sole selected item deselects it. + if (selection.has(id) && selection.size === 1) { + onSelect([], "replace"); + anchorRef.current = null; + return; + } onSelect([id], "replace"); anchorRef.current = id; }; @@ -98,9 +104,10 @@ export const ListView: React.FC = ({ bots, selection, onSelect }) }} onClick={() => onSelect([], "replace")} > - +
e.stopPropagation()}> + +
e.stopPropagation()} style={{ flex: 1, overflow: "auto", @@ -113,20 +120,26 @@ export const ListView: React.FC = ({ bots, selection, onSelect }) -
+
{ + e.stopPropagation(); + toggleAll(); + }} + style={checkBox(allVisibleSelected)} + > {allVisibleSelected ? "✓" : ""}
- sortBy("id")}> + { e.stopPropagation(); sortBy("id"); }}> ID{arrow("id")} - sortBy("fw")}> + { e.stopPropagation(); sortBy("fw"); }}> Device{arrow("fw")} - sortBy("battery")}> + { e.stopPropagation(); sortBy("battery"); }}> Battery{arrow("battery")} - sortBy("state")}> + { e.stopPropagation(); sortBy("state"); }}> State{arrow("state")} @@ -137,7 +150,10 @@ export const ListView: React.FC = ({ bots, selection, onSelect }) return ( rowClick(e, b.id)} + onClick={(e) => { + e.stopPropagation(); + rowClick(e, b.id); + }} style={{ cursor: "pointer", background: checked ? "rgba(228,3,46,.07)" : "transparent", @@ -188,7 +204,9 @@ export const ListView: React.FC = ({ bots, selection, onSelect })
- +
e.stopPropagation()}> + +
); }; diff --git a/dotbot/console-web/src/MapView.tsx b/dotbot/console-web/src/MapView.tsx index efb754b6..b98a75e9 100644 --- a/dotbot/console-web/src/MapView.tsx +++ b/dotbot/console-web/src/MapView.tsx @@ -337,7 +337,13 @@ export const MapView: React.FC = (props) => { if (p) props.onAddWaypoint(p); return; } - props.onSelect([b.id], e.shiftKey || e.metaKey || e.ctrlKey ? "toggle" : "replace"); + if (e.shiftKey || e.metaKey || e.ctrlKey) { + props.onSelect([b.id], "toggle"); + } else if (props.selection.has(b.id) && props.selection.size === 1) { + props.onSelect([], "replace"); // click the sole selected bot again = deselect + } else { + props.onSelect([b.id], "replace"); + } }} onPointerEnter={() => setHoverId(b.id)} onPointerLeave={() => setHoverId((h) => (h === b.id ? null : h))} From d1cf4b48afbf192fb6b0649f872036aa2ab5320e Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Thu, 9 Jul 2026 20:43:28 +0200 Subject: [PATCH 17/27] dotbot/console-web: screen-relative pad steering (drag where the bot should go) The pad closes the heading loop itself at 10 Hz (P on heading error, alignment-scaled throttle with a floor that keeps the motion-derived heading observable). Replaces body-relative throttle/yaw teleop, which felt erratic; verified convergent against the simulator. AI-assisted: Claude Fable 5 --- dotbot/console-web/src/Joystick.tsx | 56 ++++++++++++++++++----------- 1 file changed, 36 insertions(+), 20 deletions(-) diff --git a/dotbot/console-web/src/Joystick.tsx b/dotbot/console-web/src/Joystick.tsx index 191e99b8..b3109467 100644 --- a/dotbot/console-web/src/Joystick.tsx +++ b/dotbot/console-web/src/Joystick.tsx @@ -5,33 +5,49 @@ import { UnifiedBot } from "./types"; // v1 drive pad: 64px rounded square, crosshair guides, LED-colored knob with // the bot's live heading pointer (single) or an accent knob with a xN count -// (group). Differential-drive mapping matches the classic frontend: -// dir = -128*y/200, angle = 128*x/200, +/- deadband offset, clamp [-128,127], -// published at 10 Hz while held (firmware deadman stops motors on release). +// (group). +// +// Control model: SCREEN-relative steering. The pad vector is the direction +// you want the bot to move on the (north-up) map; the pad closes the heading +// loop itself at 10 Hz: steer = P * heading-error, throttle scales with +// deflection and drops while the bot is badly misaligned. This replaces the +// classic body-relative mapping (y = throttle along heading, x = yaw rate), +// which felt erratic against the simulator's motion-derived heading. +// +// Caveat baked into the throttle floor: the controller derives heading from +// MOTION (successive position fixes), so a bot spinning in place reports no +// heading change - the loop always keeps some forward speed so the heading +// stays observable. const SPEED_OFFSET = 30; const PAD = 64; const R = 20; // knob travel radius, as in v1 -function speeds(dx: number, dy: number): { left: number; right: number } { - const px = (dx / R) * 100; - const py = (dy / R) * 100; - const dir = (128 * py / 200) * -1; - const angle = (128 * px) / 200; - // Sign verified against the simulator: right wheel faster turns the bot - // clockwise on the north-up map, so pad-right = turn right on screen. - // NOTE: the classic frontend used the opposite sign against real hardware - - // validate on a real DotBot (a mismatch here would point at a left/right - // label swap between dotbot_simulator.py and the firmware). - let left = dir - angle; - let right = dir + angle; +const clampPwm = (v: number) => Math.max(-128, Math.min(127, Math.trunc(v))); + +function steerTowards(bot: UnifiedBot, kx: number, ky: number): { left: number; right: number } { + const mag = Math.min(1, Math.hypot(kx, ky) / R); + if (mag < 0.12) return { left: 0, right: 0 }; + // Desired motion direction in the controller's convention (0 = north/+y, + // positive CCW): dir = -atan2(ax, ay) with arena ax = kx, ay = -ky (screen + // y grows down, arena y grows up). + const desired = (-Math.atan2(kx, -ky) * 180) / Math.PI; + let error = bot.heading === null ? 0 : desired - bot.heading; + while (error > 180) error -= 360; + while (error < -180) error += 360; + // Throttle: floor keeps the heading observable; alignment factor slows the + // bot down while it still points the wrong way. + const align = Math.max(0, Math.cos((error * Math.PI) / 180)); + const throttle = 25 + mag * 55 * align; + // Verified against the simulator: left channel faster = CCW on the + // north-up map = heading (CCW-positive) increases. + const steer = Math.max(-55, Math.min(55, error * 0.9)); + let left = throttle + steer; + let right = throttle - steer; if (left > 0) left += SPEED_OFFSET; if (left < 0) left -= SPEED_OFFSET; if (right > 0) right += SPEED_OFFSET; if (right < 0) right -= SPEED_OFFSET; - return { - left: Math.max(-128, Math.min(127, Math.trunc(left))), - right: Math.max(-128, Math.min(127, Math.trunc(right))), - }; + return { left: clampPwm(left), right: clampPwm(right) }; } interface PadProps { @@ -57,8 +73,8 @@ export const Pad: React.FC = ({ targets, disabled }) => { useEffect(() => { if (!active) return; const t = setInterval(() => { - const { left, right } = speeds(knobRef.current.x, knobRef.current.y); targetsRef.current.forEach((b) => { + const { left, right } = steerTowards(b, knobRef.current.x, knobRef.current.y); putMoveRaw(b.id, b.application, left, right).catch(() => {}); }); }, 100); From 0a8667d03435cf846e992fa6824a66b7eaeea74d Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Fri, 10 Jul 2026 09:03:07 +0200 Subject: [PATCH 18/27] dotbot/config: add [run.controller] swarmit_url and --swarmit-url AI-assisted: Claude Fable 5 --- dotbot/__init__.py | 1 + dotbot/config.py | 24 ++++++++++----- dotbot/controller.py | 2 ++ dotbot/controller_app.py | 12 ++++++++ dotbot/tests/test_config.py | 47 +++++++++++++++++++++++++++++ dotbot/tests/test_controller_app.py | 45 +++++++++++++++++++++++++++ 6 files changed, 124 insertions(+), 7 deletions(-) diff --git a/dotbot/__init__.py b/dotbot/__init__.py index 216b851b..4e89fd18 100644 --- a/dotbot/__init__.py +++ b/dotbot/__init__.py @@ -17,6 +17,7 @@ MQTT_PORT_DEFAULT = 1883 MAP_SIZE_DEFAULT = "2000x2000" # in mm unit SIMULATOR_INIT_STATE_DEFAULT = "simulator_init_state.toml" +SWARMIT_URL_DEFAULT = "http://localhost:8001" # swarmit server default port def pydotbot_version() -> str: diff --git a/dotbot/config.py b/dotbot/config.py index 7027c43d..10e1e317 100644 --- a/dotbot/config.py +++ b/dotbot/config.py @@ -145,6 +145,7 @@ class ControllerSection(_Strict): headless: bool | None = None gw_address: str | None = None simulator_init_state: str | None = None + swarmit_url: str | None = None class GatewaySection(_Strict): @@ -292,11 +293,13 @@ def _env_candidates(section: str | None, key: str) -> tuple[str, ...]: """Env-var names to check, in priority order (Cargo's mechanical mapping). Sectioned key -> `DOTBOT_
_`, then the shared `DOTBOT_` - alias. Top-level key -> just `DOTBOT_`. + alias. Top-level key -> just `DOTBOT_`. A nested section like + `run.controller` flattens its dots: `DOTBOT_RUN_CONTROLLER_`. """ key_part = key.upper().replace("-", "_") if section: - return (f"DOTBOT_{section.upper()}_{key_part}", f"DOTBOT_{key_part}") + section_part = section.upper().replace(".", "_") + return (f"DOTBOT_{section_part}_{key_part}", f"DOTBOT_{key_part}") return (f"DOTBOT_{key_part}",) @@ -318,11 +321,17 @@ def _file_value( key: str, deployment: Deployment | None, ) -> Any: - """The value this key has in the file layer: section > deployment > top-level.""" + """The value this key has in the file layer: section > deployment > top-level. + + `section` may be nested (dot-separated, e.g. `run.controller`); each part + is walked with getattr. + """ if config is None: return None if section is not None: - section_obj = getattr(config, section, None) + section_obj: Any = config + for part in section.split("."): + section_obj = getattr(section_obj, part, None) value = getattr(section_obj, key, None) if value is not None: return value @@ -348,9 +357,10 @@ def resolve( `flag` > env (`DOTBOT_
_`, then shared `DOTBOT_`) > file (section > deployment > top-level) > `default`. - `section` is one of `SECTIONS` for a per-namespace key, or `None` for a - top-level shared key (e.g. `conn`, `swarm_id`). Env values are coerced to - the type of `default`. + `section` is one of `SECTIONS` for a per-namespace key, a dotted path for + a nested table (e.g. `run.controller`), or `None` for a top-level shared + key (e.g. `conn`, `swarm_id`). Env values are coerced to the type of + `default`. """ if flag is not None: return flag diff --git a/dotbot/controller.py b/dotbot/controller.py index 043862d2..e741bc76 100644 --- a/dotbot/controller.py +++ b/dotbot/controller.py @@ -39,6 +39,7 @@ SERIAL_BAUDRATE_DEFAULT, SERIAL_PORT_DEFAULT, SIMULATOR_INIT_STATE_DEFAULT, + SWARMIT_URL_DEFAULT, ) from dotbot.adapter import ( DotBotSimulatorAdapter, @@ -135,6 +136,7 @@ class ControllerSettings: log_output: str = os.path.join(os.getcwd(), "pydotbot.log") csv_data_output: Optional[str] = None simulator_init_state: str = SIMULATOR_INIT_STATE_DEFAULT + swarmit_url: str = SWARMIT_URL_DEFAULT def lh2_distance(last: DotBotLH2Position, new: DotBotLH2Position) -> float: diff --git a/dotbot/controller_app.py b/dotbot/controller_app.py index 117f8377..e3aee008 100644 --- a/dotbot/controller_app.py +++ b/dotbot/controller_app.py @@ -22,6 +22,7 @@ GATEWAY_ADDRESS_DEFAULT, MAP_SIZE_DEFAULT, SIMULATOR_INIT_STATE_DEFAULT, + SWARMIT_URL_DEFAULT, pydotbot_version, ) from dotbot.cli._cfg import from_config @@ -226,6 +227,14 @@ def _maybe_scaffold_sim_state(explicit_init_state): type=click.Path(dir_okay=False), help=f"Path to the simulator initial state .toml file. Defaults to '{SIMULATOR_INIT_STATE_DEFAULT}'.", ) +@click.option( + "--swarmit-url", + type=str, + help=( + "Base URL of the swarmit server the controller proxies /swarmit/* " + f"requests to (for the web console). Defaults to '{SWARMIT_URL_DEFAULT}'." + ), +) @click.pass_context def main( ctx, @@ -237,6 +246,7 @@ def main( map_size, background_map, simulator_init_state, + swarmit_url, headless, verbose, log_level, @@ -261,6 +271,7 @@ def main( # legacy `--config-path` fallback that follows. conn = from_config(ctx, "conn", "conn", "run") swarm_id = from_config(ctx, "swarm_id", "swarm_id", "run") + swarmit_url = from_config(ctx, "swarmit_url", "swarmit_url", "run.controller") conn = conn if conn is not None else file_data.get("conn") swarm_id = swarm_id if swarm_id is not None else file_data.get("swarm_id") @@ -296,6 +307,7 @@ def main( "map_size": map_size, "background_map": background_map, "simulator_init_state": simulator_init_state, + "swarmit_url": swarmit_url, "headless": True if headless else None, "verbose": verbose, "log_level": log_level, diff --git a/dotbot/tests/test_config.py b/dotbot/tests/test_config.py index b3e88df7..25dc123e 100644 --- a/dotbot/tests/test_config.py +++ b/dotbot/tests/test_config.py @@ -309,3 +309,50 @@ def test_resolve_bad_int_env_raises(): cfg.resolve( "http_port", section="run", environ={"DOTBOT_HTTP_PORT": "x"}, default=8000 ) + + +def test_resolve_nested_section_from_file(): + config = cfg.DotbotConfig( + run=cfg.RunSection( + controller=cfg.ControllerSection(swarmit_url="http://lab:9001") + ) + ) + got = cfg.resolve( + "swarmit_url", + section="run.controller", + config=config, + environ={}, + default="http://localhost:8001", + ) + assert got == "http://lab:9001" + + +def test_resolve_nested_section_env_name(): + got = cfg.resolve( + "swarmit_url", + section="run.controller", + environ={"DOTBOT_RUN_CONTROLLER_SWARMIT_URL": "http://env:9001"}, + default="http://localhost:8001", + ) + assert got == "http://env:9001" + + +def test_resolve_nested_section_shared_env_alias(): + got = cfg.resolve( + "swarmit_url", + section="run.controller", + environ={"DOTBOT_SWARMIT_URL": "http://env:9001"}, + default="http://localhost:8001", + ) + assert got == "http://env:9001" + + +def test_resolve_nested_section_missing_falls_to_default(): + got = cfg.resolve( + "swarmit_url", + section="run.controller", + config=cfg.DotbotConfig(), + environ={}, + default="http://localhost:8001", + ) + assert got == "http://localhost:8001" diff --git a/dotbot/tests/test_controller_app.py b/dotbot/tests/test_controller_app.py index 613a9209..5e2050f8 100644 --- a/dotbot/tests/test_controller_app.py +++ b/dotbot/tests/test_controller_app.py @@ -72,6 +72,51 @@ def test_run_controller_uses_selected_deployment(controller, _asyncio_run, tmp_p assert settings.adapter == "dotbot-simulator" +@pytest.mark.skipif(sys.platform == "win32", reason="Doesn't work on Windows") +@patch("dotbot.controller_app.asyncio.run") +@patch("dotbot.controller_app.Controller") +def test_run_controller_swarmit_url_flag(controller, _asyncio_run): + runner = CliRunner() + result = runner.invoke( + main, ["--conn", "simulator", "--swarmit-url", "http://lab:9001"] + ) + assert result.exit_code == 0, result.output + settings = controller.call_args.args[0] + assert settings.swarmit_url == "http://lab:9001" + + +@pytest.mark.skipif(sys.platform == "win32", reason="Doesn't work on Windows") +@patch("dotbot.controller_app.asyncio.run") +@patch("dotbot.controller_app.Controller") +def test_run_controller_swarmit_url_from_unified_config( + controller, _asyncio_run, tmp_path +): + """`[run.controller] swarmit_url` in dotbot.toml reaches the settings; + without it the built-in default applies.""" + from dotbot.cli.main import cli + + config_file = tmp_path / "dotbot.toml" + config_file.write_text( + """ +conn = "simulator" + +[run.controller] +swarmit_url = "http://lab:9001" +""" + ) + + runner = CliRunner() + result = runner.invoke(cli, ["-c", str(config_file), "run", "controller"]) + assert result.exit_code == 0, result.output + settings = controller.call_args.args[0] + assert settings.swarmit_url == "http://lab:9001" + + result = runner.invoke(main, ["--conn", "simulator"]) + assert result.exit_code == 0, result.output + settings = controller.call_args.args[0] + assert settings.swarmit_url == "http://localhost:8001" + + def test_main_without_conn_errors(): """No `--conn` → a clear error listing the connection forms.""" runner = CliRunner() From a4e22038b1b4633568583509ac5f24110f808ed2 Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Fri, 10 Jul 2026 09:03:07 +0200 Subject: [PATCH 19/27] dotbot/server: serve the console at /console and proxy /swarmit/* AI-assisted: Claude Fable 5 --- dotbot/server.py | 67 ++++++++++++++++++++++- dotbot/tests/test_server.py | 103 ++++++++++++++++++++++++++++++++++++ 2 files changed, 169 insertions(+), 1 deletion(-) diff --git a/dotbot/server.py b/dotbot/server.py index 0e64a3bd..643b41ba 100644 --- a/dotbot/server.py +++ b/dotbot/server.py @@ -14,13 +14,15 @@ FastAPI, HTTPException, Query, + Request, WebSocket, WebSocketDisconnect, ) from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import Response +from fastapi.responses import Response, StreamingResponse from fastapi.staticfiles import StaticFiles from pydantic import TypeAdapter, ValidationError +from starlette.background import BackgroundTask from starlette.middleware.base import BaseHTTPMiddleware from dotbot import pydotbot_version @@ -362,6 +364,55 @@ async def ws_dotbots(websocket: WebSocket): LOGGER.debug("WebSocket client disconnected") +# Timeouts for the swarmit proxy: fail fast when the server is down, but +# never time out reads - /events and /flash/stream are long-lived SSE. +SWARMIT_PROXY_TIMEOUT = httpx.Timeout(5.0, read=None) + + +@api.api_route( + path="/swarmit/{path:path}", + methods=["GET", "POST"], + include_in_schema=False, +) +async def swarmit_proxy(path: str, request: Request): + """Forward /swarmit/* to the configured swarmit server (same-origin for + the web console; the streaming body keeps SSE responses live).""" + base = api.controller.settings.swarmit_url.rstrip("/") + client = httpx.AsyncClient(timeout=SWARMIT_PROXY_TIMEOUT) + upstream_request = client.build_request( + method=request.method, + url=f"{base}/{path}", + params=request.query_params, + headers={ + k: v + for k, v in request.headers.items() + if k.lower() in ("content-type", "accept") + }, + content=await request.body(), + ) + try: + upstream = await client.send(upstream_request, stream=True) + except httpx.HTTPError as exc: + await client.aclose() + LOGGER.debug("swarmit server unreachable", url=f"{base}/{path}", error=str(exc)) + return Response(status_code=502, content=b"swarmit server unreachable") + + async def cleanup(): + await upstream.aclose() + await client.aclose() + + return StreamingResponse( + upstream.aiter_raw(), + status_code=upstream.status_code, + headers={ + k: v + for k, v in upstream.headers.items() + if k.lower() in ("content-type", "cache-control") + }, + background=BackgroundTask(cleanup), + ) + + # Mount static files after all routes are defined FRONTEND_DIR = os.path.join(os.path.dirname(__file__), "frontend", "build") if os.path.isdir(FRONTEND_DIR): @@ -375,3 +426,17 @@ async def ws_dotbots(websocket: WebSocket): "frontend: cd dotbot/frontend && npm install && npm run build", FRONTEND_DIR, ) + +# The unified console (map-first PyDotBot + swarmit UI), served side by side +# with the classic frontend while it matures towards replacing it. +CONSOLE_DIR = os.path.join(os.path.dirname(__file__), "console-web", "dist") +if os.path.isdir(CONSOLE_DIR): + api.mount( + "/console", StaticFiles(directory=CONSOLE_DIR, html=True), name="console" + ) +else: + LOGGER.info( + "Console build not found at %s; /console will be unavailable. " + "Build it with: cd dotbot/console-web && npm install && npm run build", + CONSOLE_DIR, + ) diff --git a/dotbot/tests/test_server.py b/dotbot/tests/test_server.py index b7235681..b97b149e 100644 --- a/dotbot/tests/test_server.py +++ b/dotbot/tests/test_server.py @@ -579,6 +579,109 @@ def mock_async_client(*args, **kwargs): assert response.headers["X-Upstream"] == "mock" +class _MockByteStream(httpx.AsyncByteStream): + """Streamable body for MockTransport responses (a plain `content=` body + counts as already consumed, which the streaming proxy rejects).""" + + def __init__(self, chunks): + self._chunks = chunks + + async def __aiter__(self): + for chunk in self._chunks: + yield chunk + + +@pytest.mark.asyncio +async def test_swarmit_proxy_forwards(monkeypatch): + + async def mock_send(request: httpx.Request): + assert request.url == httpx.URL("http://swarmit-host:9001/status") + return httpx.Response( + status_code=200, + stream=_MockByteStream([b'{"response": {}}']), + headers={"Content-Type": "application/json"}, + ) + + transport = httpx.MockTransport(mock_send) + RealAsyncClient = httpx.AsyncClient + + def mock_async_client(*args, **kwargs): + kwargs.pop("transport", None) + return RealAsyncClient(transport=transport, **kwargs) + + import dotbot.server as server_module + + monkeypatch.setattr(server_module.httpx, "AsyncClient", mock_async_client) + api.controller.settings.swarmit_url = "http://swarmit-host:9001" + + client = TestClient(api) + response = client.get("/swarmit/status") + + assert response.status_code == 200 + assert response.content == b'{"response": {}}' + assert response.headers["Content-Type"] == "application/json" + + +@pytest.mark.asyncio +async def test_swarmit_proxy_forwards_post_body(monkeypatch): + + async def mock_send(request: httpx.Request): + assert request.url == httpx.URL("http://swarmit-host:9001/start") + assert request.method == "POST" + assert request.content == b'{"devices": []}' + assert request.headers["content-type"] == "application/json" + return httpx.Response( + status_code=200, stream=_MockByteStream([b'{"result": "ok"}']) + ) + + transport = httpx.MockTransport(mock_send) + RealAsyncClient = httpx.AsyncClient + + def mock_async_client(*args, **kwargs): + kwargs.pop("transport", None) + return RealAsyncClient(transport=transport, **kwargs) + + import dotbot.server as server_module + + monkeypatch.setattr(server_module.httpx, "AsyncClient", mock_async_client) + api.controller.settings.swarmit_url = "http://swarmit-host:9001" + + client = TestClient(api) + response = client.post( + "/swarmit/start", + content=b'{"devices": []}', + headers={"Content-Type": "application/json"}, + ) + + assert response.status_code == 200 + assert response.content == b'{"result": "ok"}' + + +@pytest.mark.asyncio +async def test_swarmit_proxy_unreachable(monkeypatch): + + async def mock_send_failed(*args, **kwargs): + raise httpx.ConnectError("connection failed") + + transport = httpx.MockTransport(mock_send_failed) + RealAsyncClient = httpx.AsyncClient + + def mock_async_client(*args, **kwargs): + kwargs.pop("transport", None) + return RealAsyncClient(transport=transport, **kwargs) + + import dotbot.server as server_module + + monkeypatch.setattr(server_module.httpx, "AsyncClient", mock_async_client) + api.controller.settings.swarmit_url = "http://swarmit-host:9001" + + client = TestClient(api) + response = client.get("/swarmit/status") + + assert response.status_code == 502 + assert b"swarmit server unreachable" in response.content + + @pytest.mark.asyncio async def test_reverse_proxy_middleware_connect_error(monkeypatch): From d6c9c837b83b3ea51b924aca439b870faa4c2d59 Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Fri, 10 Jul 2026 09:03:07 +0200 Subject: [PATCH 20/27] dotbot/console-web: relative asset base and live host label AI-assisted: Claude Fable 5 --- dotbot/console-web/src/App.tsx | 2 +- dotbot/console-web/vite.config.ts | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/dotbot/console-web/src/App.tsx b/dotbot/console-web/src/App.tsx index 2c386b79..fe0b6b0b 100644 --- a/dotbot/console-web/src/App.tsx +++ b/dotbot/console-web/src/App.tsx @@ -213,7 +213,7 @@ export const App: React.FC = () => { >
DotBots
- dotbot.local:8000 + {window.location.host}
Date: Fri, 10 Jul 2026 09:14:26 +0200 Subject: [PATCH 21/27] dotbot/console-web: add vitest suite, eslint, and typecheck script AI-assisted: Claude Fable 5 --- dotbot/console-web/eslint.config.mjs | 10 + dotbot/console-web/package-lock.json | 2216 ++++++++++++++++++++++- dotbot/console-web/package.json | 12 +- dotbot/console-web/src/api.test.ts | 34 + dotbot/console-web/src/api.ts | 35 +- dotbot/console-web/src/missions.test.ts | 73 + dotbot/console-web/src/types.ts | 2 +- dotbot/console-web/src/useFleet.test.ts | 106 ++ dotbot/console-web/src/useFleet.ts | 4 +- dotbot/console-web/vite.config.ts | 5 + 10 files changed, 2415 insertions(+), 82 deletions(-) create mode 100644 dotbot/console-web/eslint.config.mjs create mode 100644 dotbot/console-web/src/api.test.ts create mode 100644 dotbot/console-web/src/missions.test.ts create mode 100644 dotbot/console-web/src/useFleet.test.ts diff --git a/dotbot/console-web/eslint.config.mjs b/dotbot/console-web/eslint.config.mjs new file mode 100644 index 00000000..aea6b8f7 --- /dev/null +++ b/dotbot/console-web/eslint.config.mjs @@ -0,0 +1,10 @@ +// @ts-check + +import js from "@eslint/js"; +import { defineConfig } from "eslint/config"; +import tseslint from "typescript-eslint"; + +export default defineConfig( + js.configs.recommended, + tseslint.configs.recommended, +); diff --git a/dotbot/console-web/package-lock.json b/dotbot/console-web/package-lock.json index abdfa86a..c851ec83 100644 --- a/dotbot/console-web/package-lock.json +++ b/dotbot/console-web/package-lock.json @@ -12,12 +12,31 @@ "react-dom": "^18.2.0" }, "devDependencies": { + "@eslint/js": "^10.0.1", "@types/react": "^18.2.43", "@types/react-dom": "^18.2.17", "@vitejs/plugin-react": "^4.2.1", + "@vitest/coverage-v8": "^2.1.9", + "eslint": "^10.6.0", "puppeteer-core": "^24.43.1", "typescript": "^5.2.2", - "vite": "^5.0.8" + "typescript-eslint": "^8.63.0", + "vite": "^5.0.8", + "vitest": "^2.1.9" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/@babel/code-frame": { @@ -302,6 +321,13 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -693,6 +719,313 @@ "node": ">=12" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -743,6 +1076,17 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@puppeteer/browsers": { "version": "2.13.2", "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.13.2.tgz", @@ -1187,6 +1531,13 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1194,6 +1545,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "26.1.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", @@ -1244,84 +1602,533 @@ "@types/node": "*" } }, - "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==", + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.63.0.tgz", + "integrity": "sha512-rvwSgqT+DHpWdzfSzPatRLm02a0GlESt++9iy3hLCDY4BgkaLcl8LBi9Yh7XGFBpwcBE/K3024QuXWTpbz4FfQ==", "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" + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.63.0", + "@typescript-eslint/type-utils": "8.63.0", + "@typescript-eslint/utils": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" }, "engines": { - "node": "^14.18.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + "@typescript-eslint/parser": "^8.63.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "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==", + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", "dev": true, "license": "MIT", "engines": { - "node": ">= 14" + "node": ">= 4" } }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "node_modules/@typescript-eslint/parser": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.63.0.tgz", + "integrity": "sha512-gwh4gvvlaVDKKxyfxMG+Gnu1u9X0OQBwyGLkbwB65dIzBKnxeRiJlNFqlI3zwVhNXJIs6qV7mlFCn/BIajlVig==", "dev": true, "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.63.0", + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/typescript-estree": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0", + "debug": "^4.4.3" + }, "engines": { - "node": ">=8" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/@typescript-eslint/project-service": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.63.0.tgz", + "integrity": "sha512-e5dh0/UI0ok53AlZ5wRkXCB32z/f2jUZqPR/ygAw5WYaSw8j9EoJWlS7wQjr/dmOaqWjnPIn2m+HhVPCMWGZVQ==", "dev": true, "license": "MIT", "dependencies": { - "color-convert": "^2.0.1" + "@typescript-eslint/tsconfig-utils": "^8.63.0", + "@typescript-eslint/types": "^8.63.0", + "debug": "^4.4.3" }, "engines": { - "node": ">=8" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/ast-types": { - "version": "0.13.4", - "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", - "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.63.0.tgz", + "integrity": "sha512-uUyfMWCnDSN8bCpcrY8nGP2BLkQ9Xn0GsipcONcpIDWhwhO4ZSyHvyS14U3X75mzxWxL3I2UZIrenTzdzcJO8A==", "dev": true, "license": "MIT", "dependencies": { - "tslib": "^2.0.1" + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0" }, "engines": { - "node": ">=4" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/bare-events": { - "version": "2.9.1", - "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.1.tgz", - "integrity": "sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==", + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.63.0.tgz", + "integrity": "sha512-sUAbkulqBAsncKnbRP3+7CtQFRKicexnj7ZwNC6ddCR7EmrXvjvdCYMJbUIqMd6lwoEriZjwLo08aS5tSjVMHg==", "dev": true, - "license": "Apache-2.0", - "peerDependencies": { - "bare-abort-controller": "*" + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.63.0.tgz", + "integrity": "sha512-Nzzh/OGxVCOjObjaj1CQF2RUasyYy2Jfuh+zZ3PjLzG2fYRriAiZLib9UKtO+CpQAS3YHiAS+ckZDclwqI1TPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/typescript-estree": "8.63.0", + "@typescript-eslint/utils": "8.63.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.63.0.tgz", + "integrity": "sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.63.0.tgz", + "integrity": "sha512-ygBkU+B7ex5UI/gKhaqexWev79uISfIv7XQCRNYO/jmD8rGLPyWLAb3KMRT6nd8Gt9bmUBi9+iX6tBdYfOY81Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.63.0", + "@typescript-eslint/tsconfig-utils": "8.63.0", + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.63.0.tgz", + "integrity": "sha512-fUKaeAvrTuQg/Tgt3nliAUSZHJM6DlCcfyEmxCvlX8kieWSStBX+5O5Fnidtc3i2JrH+9c/GL4RY2iasd/GPTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.63.0", + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/typescript-estree": "8.63.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.63.0.tgz", + "integrity": "sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.63.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "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/coverage-v8": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.9.tgz", + "integrity": "sha512-Z2cOr0ksM00MpEfyVE8KXIYPEcBFxdbLSs56L8PO0QQMxt/6bDj45uQfxoc96v05KW3clk7vvgP0qfDit9DmfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "@bcoe/v8-coverage": "^0.2.3", + "debug": "^4.3.7", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.1.7", + "magic-string": "^0.30.12", + "magicast": "^0.3.5", + "std-env": "^3.8.0", + "test-exclude": "^7.0.1", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "2.1.9", + "vitest": "2.1.9" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "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/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "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/ast-types": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", + "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/bare-events": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.1.tgz", + "integrity": "sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" }, "peerDependenciesMeta": { "bare-abort-controller": { @@ -1437,6 +2244,19 @@ "node": ">=10.0.0" } }, + "node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/browserslist": { "version": "4.28.5", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.5.tgz", @@ -1481,6 +2301,16 @@ "node": "*" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001803", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001803.tgz", @@ -1502,6 +2332,33 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/chromium-bidi": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-14.0.0.tgz", @@ -1558,6 +2415,21 @@ "dev": true, "license": "MIT" }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1593,6 +2465,23 @@ } } }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/degenerator": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", @@ -1615,6 +2504,13 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.5.389", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.389.tgz", @@ -1639,6 +2535,13 @@ "once": "^1.4.0" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -1688,6 +2591,19 @@ "node": ">=6" } }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/escodegen": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", @@ -1710,6 +2626,115 @@ "source-map": "~0.6.1" } }, + "node_modules/eslint": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.6.0.tgz", + "integrity": "sha512-6lVbcqSodALYo+4ELD0heG6lFiFxnLMuLkiMi2qV8LMp54N8tE8FT1GMH+ev4Ti00nFjNze2+Su6DsV5OQW3Dg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/esprima": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", @@ -1724,6 +2749,32 @@ "node": ">=4" } }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, "node_modules/estraverse": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", @@ -1734,6 +2785,16 @@ "node": ">=4.0" } }, + "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/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -1754,6 +2815,16 @@ "bare-events": "^2.7.0" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/extract-zip": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", @@ -1775,6 +2846,13 @@ "@types/yauzl": "^2.9.1" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-fifo": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", @@ -1782,6 +2860,20 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, "node_modules/fd-slicer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", @@ -1792,6 +2884,92 @@ "pend": "~1.2.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/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1822,42 +3000,127 @@ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", "dev": true, - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-uri": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", + "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "basic-ftp": "^5.0.2", + "data-uri-to-buffer": "^6.0.2", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" } }, - "node_modules/get-stream": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", - "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "pump": "^3.0.0" + "brace-expansion": "^2.0.2" }, "engines": { - "node": ">=8" + "node": ">=16 || 14 >=14.17" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/get-uri": { - "version": "6.0.5", - "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", - "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, "license": "MIT", - "dependencies": { - "basic-ftp": "^5.0.2", - "data-uri-to-buffer": "^6.0.2", - "debug": "^4.3.4" - }, "engines": { - "node": ">= 14" + "node": ">=8" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -1886,6 +3149,26 @@ "node": ">= 14" } }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, "node_modules/ip-address": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", @@ -1896,6 +3179,16 @@ "node": ">= 12" } }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -1906,6 +3199,96 @@ "node": ">=8" } }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -1925,6 +3308,27 @@ "node": ">=6" } }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -1938,6 +3342,46 @@ "node": ">=6" } }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -1950,6 +3394,13 @@ "loose-envify": "cli.js" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -1960,6 +3411,83 @@ "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/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/mitt": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", @@ -1993,6 +3521,13 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, "node_modules/netmask": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.1.1.tgz", @@ -2023,6 +3558,56 @@ "wrappy": "1" } }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/pac-proxy-agent": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", @@ -2054,7 +3639,75 @@ "netmask": "^2.0.2" }, "engines": { - "node": ">= 14" + "node": ">= 14" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/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/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" } }, "node_modules/pend": { @@ -2071,6 +3724,19 @@ "dev": true, "license": "ISC" }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/postcss": { "version": "8.5.16", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", @@ -2100,6 +3766,16 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/progress": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", @@ -2158,6 +3834,16 @@ "once": "^1.3.1" } }, + "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/puppeteer-core": { "version": "24.43.1", "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.43.1.tgz", @@ -2286,6 +3972,49 @@ "semver": "bin/semver.js" } }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/smart-buffer": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", @@ -2348,6 +4077,20 @@ "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": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/streamx": { "version": "2.28.0", "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.0.tgz", @@ -2375,6 +4118,22 @@ "node": ">=8" } }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -2388,6 +4147,33 @@ "node": ">=8" } }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/tar-fs": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.3.tgz", @@ -2441,6 +4227,21 @@ "streamx": "^2.12.5" } }, + "node_modules/test-exclude": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", + "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^10.2.2" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/text-decoder": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", @@ -2466,6 +4267,80 @@ } } }, + "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": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -2473,6 +4348,19 @@ "dev": true, "license": "0BSD" }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/typed-query-selector": { "version": "2.12.2", "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.2.tgz", @@ -2494,6 +4382,30 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.63.0.tgz", + "integrity": "sha512-xgwXyzG4sK9ALkBxbyGkTMMOS+imnW65iPhxCQMK83KhxyoDNW7l+IDqEf9vMdoUidHpOoS967RCq4eMiTexwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.63.0", + "@typescript-eslint/parser": "8.63.0", + "@typescript-eslint/typescript-estree": "8.63.0", + "@typescript-eslint/utils": "8.63.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/undici-types": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", @@ -2533,6 +4445,16 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", @@ -2593,6 +4515,95 @@ } } }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, "node_modules/webdriver-bidi-protocol": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.1.tgz", @@ -2600,6 +4611,49 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "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/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -2618,6 +4672,25 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -2704,6 +4777,19 @@ "fd-slicer": "~1.1.0" } }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", diff --git a/dotbot/console-web/package.json b/dotbot/console-web/package.json index 4924bc6a..bbc7fd92 100644 --- a/dotbot/console-web/package.json +++ b/dotbot/console-web/package.json @@ -6,18 +6,26 @@ "scripts": { "start": "vite", "build": "tsc && vite build", - "preview": "vite preview" + "preview": "vite preview", + "typecheck": "tsc --noEmit", + "lint": "eslint src/.", + "test": "vitest run --coverage" }, "dependencies": { "react": "^18.2.0", "react-dom": "^18.2.0" }, "devDependencies": { + "@eslint/js": "^10.0.1", "@types/react": "^18.2.43", "@types/react-dom": "^18.2.17", "@vitejs/plugin-react": "^4.2.1", + "@vitest/coverage-v8": "^2.1.9", + "eslint": "^10.6.0", "puppeteer-core": "^24.43.1", "typescript": "^5.2.2", - "vite": "^5.0.8" + "typescript-eslint": "^8.63.0", + "vite": "^5.0.8", + "vitest": "^2.1.9" } } diff --git a/dotbot/console-web/src/api.test.ts b/dotbot/console-web/src/api.test.ts new file mode 100644 index 00000000..897fabe3 --- /dev/null +++ b/dotbot/console-web/src/api.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; + +import { parseSseChunk } from "./api"; + +describe("parseSseChunk", () => { + it("parses complete frames and keeps the partial remainder", () => { + const { events, rest } = parseSseChunk( + 'data: {"a": 1}\n\ndata: {"a": 2}\n\ndata: {"a"', + ); + expect(events).toEqual([{ a: 1 }, { a: 2 }]); + expect(rest).toBe('data: {"a"'); + }); + + it("skips malformed JSON frames", () => { + const { events } = parseSseChunk('data: not-json\n\ndata: {"ok": true}\n\n'); + expect(events).toEqual([{ ok: true }]); + }); + + it("skips frames without a data line", () => { + const { events } = parseSseChunk(': keepalive\n\ndata: {"ok": true}\n\n'); + expect(events).toEqual([{ ok: true }]); + }); + + it("finds the data line in a multi-field frame", () => { + const { events } = parseSseChunk('event: chunk\ndata: {"acked": 12}\n\n'); + expect(events).toEqual([{ acked: 12 }]); + }); + + it("returns everything as remainder when no frame is complete", () => { + const { events, rest } = parseSseChunk("data: {"); + expect(events).toEqual([]); + expect(rest).toBe("data: {"); + }); +}); diff --git a/dotbot/console-web/src/api.ts b/dotbot/console-web/src/api.ts index e4ea4ef7..6d084e33 100644 --- a/dotbot/console-web/src/api.ts +++ b/dotbot/console-web/src/api.ts @@ -88,6 +88,26 @@ export interface FlashEvent { message?: string; } +// Split an SSE buffer into parsed `data:` payloads plus the trailing +// incomplete remainder (kept for the next chunk). Malformed frames are +// skipped. +export function parseSseChunk(buf: string): { events: T[]; rest: string } { + const events: T[] = []; + let idx; + while ((idx = buf.indexOf("\n\n")) >= 0) { + const frame = buf.slice(0, idx); + buf = buf.slice(idx + 2); + const line = frame.split("\n").find((l) => l.startsWith("data: ")); + if (!line) continue; + try { + events.push(JSON.parse(line.slice(6))); + } catch { + /* skip malformed frame */ + } + } + return { events, rest: buf }; +} + // POST /flash/stream and feed each SSE event to the callback. export async function flashStream( firmwareB64: string, @@ -110,18 +130,9 @@ export async function flashStream( const { done, value } = await reader.read(); if (done) break; buf += decoder.decode(value, { stream: true }); - let idx; - while ((idx = buf.indexOf("\n\n")) >= 0) { - const frame = buf.slice(0, idx); - buf = buf.slice(idx + 2); - const line = frame.split("\n").find((l) => l.startsWith("data: ")); - if (!line) continue; - try { - onEvent(JSON.parse(line.slice(6))); - } catch { - /* skip malformed frame */ - } - } + const { events, rest } = parseSseChunk(buf); + buf = rest; + events.forEach(onEvent); } } diff --git a/dotbot/console-web/src/missions.test.ts b/dotbot/console-web/src/missions.test.ts new file mode 100644 index 00000000..dc39917a --- /dev/null +++ b/dotbot/console-web/src/missions.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; + +import { deriveMissions } from "./TestbedRail"; +import { PlannedMission, UnifiedBot } from "./types"; + +const bot = (id: string, over: Partial = {}): UnifiedBot => ({ + id, + state: "Running", + position: { x: 0, y: 0 }, + heading: null, + battery: 3.9, + led: null, + deviceType: "DotBotV3", + application: 0, + drivable: true, + nav: "drive", + waypoints: [], + trail: [], + ...over, +}); + +describe("deriveMissions", () => { + it("keeps a planned mission bound to its bots", () => { + const planned: PlannedMission[] = [ + { + key: "aaaa", + ids: ["aaaa"], + waypoints: [ + { x: 1, y: 1 }, + { x: 2, y: 2 }, + ], + }, + ]; + const [m] = deriveMissions([bot("aaaa")], planned); + expect(m.phase).toBe("planned"); + expect(m.n).toBe(2); + expect(m.ids).toEqual(["aaaa"]); + }); + + it("drops a planned mission whose bots are gone", () => { + const planned: PlannedMission[] = [ + { key: "gone", ids: ["gone"], waypoints: [{ x: 1, y: 1 }] }, + ]; + expect(deriveMissions([bot("aaaa")], planned)).toEqual([]); + }); + + it("groups active bots by the mission TAIL (own start prepended)", () => { + // The controller stores [own-start, ...targets] per bot: same targets, + // different starts, must land in ONE mission. + const t = [ + { x: 500, y: 500 }, + { x: 900, y: 900 }, + ]; + const a = bot("aaaa", { nav: "auto", waypoints: [{ x: 1, y: 1 }, ...t] }); + const b = bot("bbbb", { nav: "auto", waypoints: [{ x: 2, y: 2 }, ...t] }); + const missions = deriveMissions([a, b], []); + expect(missions).toHaveLength(1); + expect(missions[0].phase).toBe("active"); + expect(missions[0].count).toBe(2); + expect(missions[0].n).toBe(2); + }); + + it("treats a single-entry waypoint list as the target itself", () => { + const a = bot("aaaa", { nav: "auto", waypoints: [{ x: 500, y: 500 }] }); + const [m] = deriveMissions([a], []); + expect(m.n).toBe(1); + }); + + it("ignores bots that are not navigating", () => { + const a = bot("aaaa", { waypoints: [{ x: 1, y: 1 }] }); // nav=drive + expect(deriveMissions([a], [])).toEqual([]); + }); +}); diff --git a/dotbot/console-web/src/types.ts b/dotbot/console-web/src/types.ts index 3acd02c7..d91d7107 100644 --- a/dotbot/console-web/src/types.ts +++ b/dotbot/console-web/src/types.ts @@ -59,7 +59,7 @@ export interface SwarmitNode { pos_y: number; } -// The merged per-bot object the UI binds to (see plans/unified-web-ui/reference.html). +// The merged per-bot object the UI binds to (controller + swarmit joined by address). export interface UnifiedBot { id: string; // hex address, the join key state: BotState; diff --git a/dotbot/console-web/src/useFleet.test.ts b/dotbot/console-web/src/useFleet.test.ts new file mode 100644 index 00000000..73f6d675 --- /dev/null +++ b/dotbot/console-web/src/useFleet.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vitest"; + +import { PyDotBot, SwarmitNode } from "./types"; +import { deriveState, merge } from "./useFleet"; + +const py = (over: Partial = {}): PyDotBot => ({ + address: "badcafe111111111", + application: 0, + status: 0, + ...over, +}); + +const sw = (over: Partial = {}): SwarmitNode => ({ + device: "DotBotV3", + status: "Running", + battery: 3900, + pos_x: 100, + pos_y: 200, + ...over, +}); + +describe("deriveState", () => { + it("control-plane INACTIVE/LOST wins over the swarmit state", () => { + expect(deriveState(py({ status: 1 }), sw())).toBe("Inactive"); + expect(deriveState(py({ status: 2 }), sw({ status: "Programming" }))).toBe( + "Inactive", + ); + }); + + it("passes swarmit lifecycle states through", () => { + for (const s of [ + "Running", + "Programming", + "Bootloader", + "Stopping", + "Resetting", + ]) { + expect(deriveState(py(), sw({ status: s }))).toBe(s); + } + }); + + it("maps an unknown swarmit state to Inactive", () => { + expect(deriveState(py(), sw({ status: "Off" }))).toBe("Inactive"); + }); + + it("control-plane-only active bot is Running", () => { + expect(deriveState(py(), undefined)).toBe("Running"); + }); + + it("no data at all is Inactive", () => { + expect(deriveState(undefined, undefined)).toBe("Inactive"); + }); +}); + +describe("merge", () => { + it("unions both planes and sorts by id", () => { + const bots = merge( + { bbbb: py({ address: "bbbb" }) }, + { aaaa: sw(), bbbb: sw() }, + ); + expect(bots.map((b) => b.id)).toEqual(["aaaa", "bbbb"]); + }); + + it("prefers the controller position, falls back to swarmit", () => { + const [a] = merge( + { a: py({ address: "a", lh2_position: { x: 1, y: 2 } }) }, + { a: sw() }, + ); + expect(a.position).toEqual({ x: 1, y: 2 }); + const [b] = merge({}, { b: sw() }); + expect(b.position).toEqual({ x: 100, y: 200 }); + }); + + it("treats direction -1000 (unknown) as no heading", () => { + const [a] = merge({ a: py({ address: "a", direction: -1000 }) }, {}); + expect(a.heading).toBeNull(); + const [b] = merge({ b: py({ address: "b", direction: 45 }) }, {}); + expect(b.heading).toBe(45); + }); + + it("converts a swarmit-only battery from mV to V", () => { + const [a] = merge({}, { a: sw({ battery: 3900 }) }); + expect(a.battery).toBeCloseTo(3.9); + }); + + it("only an active Running control-plane bot is drivable", () => { + const [a] = merge({ a: py({ address: "a" }) }, {}); + expect(a.drivable).toBe(true); + // swarmit-only (e.g. sitting in the bootloader): never drivable + const [b] = merge({}, { b: sw({ status: "Bootloader" }) }); + expect(b.drivable).toBe(false); + // known to the controller but flashing right now: not drivable + const [c] = merge( + { c: py({ address: "c" }) }, + { c: sw({ status: "Programming" }) }, + ); + expect(c.drivable).toBe(false); + }); + + it("maps firmware AUTO mode to nav=auto", () => { + const [a] = merge({ a: py({ address: "a", mode: 1 }) }, {}); + expect(a.nav).toBe("auto"); + const [b] = merge({ b: py({ address: "b", mode: 0 }) }, {}); + expect(b.nav).toBe("drive"); + }); +}); diff --git a/dotbot/console-web/src/useFleet.ts b/dotbot/console-web/src/useFleet.ts index ddcf9ee5..02d6b0e0 100644 --- a/dotbot/console-web/src/useFleet.ts +++ b/dotbot/console-web/src/useFleet.ts @@ -12,7 +12,7 @@ import { const TRAIL_MAX = 200; -function deriveState( +export function deriveState( py: PyDotBot | undefined, sw: SwarmitNode | undefined, ): BotState { @@ -29,7 +29,7 @@ function deriveState( return "Inactive"; } -function merge( +export function merge( pyBots: Record, swNodes: Record, ): UnifiedBot[] { diff --git a/dotbot/console-web/vite.config.ts b/dotbot/console-web/vite.config.ts index 4a9ddb5e..d53ff6ca 100644 --- a/dotbot/console-web/vite.config.ts +++ b/dotbot/console-web/vite.config.ts @@ -1,3 +1,4 @@ +/// import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; @@ -28,4 +29,8 @@ export default defineConfig({ }, }, }, + test: { + environment: "node", + include: ["src/**/*.test.ts"], + }, }); From eced9e992a202737c55a102dbc237239406d64ef Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Fri, 10 Jul 2026 09:14:26 +0200 Subject: [PATCH 22/27] .github/workflows: check console-web (lint, typecheck, tests, build) AI-assisted: Claude Fable 5 --- .github/workflows/continuous-integration.yml | 31 +++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index 685380c9..de131975 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -118,8 +118,37 @@ jobs: name: frontend path: ./dotbot/frontend/build + console: + name: check console + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up nodejs + uses: actions/setup-node@v4 + with: + node-version: "20" + - run: npm install + working-directory: ./dotbot/console-web + - run: npm run lint + working-directory: ./dotbot/console-web + - run: npm run typecheck + working-directory: ./dotbot/console-web + - run: npm run test + working-directory: ./dotbot/console-web + - run: npm run build + working-directory: ./dotbot/console-web + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + verbose: true + - name: Upload console build + uses: actions/upload-artifact@v4 + with: + name: console + path: ./dotbot/console-web/dist + package: - needs: [test, doc, frontend, control_loop] + needs: [test, doc, frontend, console, control_loop] name: build source package runs-on: ${{ matrix.os }} strategy: From 0ba08fa168ece43a6a2c33349c6562fa1cd25875 Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Fri, 10 Jul 2026 09:14:26 +0200 Subject: [PATCH 23/27] tox: lint console-web in the web env AI-assisted: Claude Fable 5 --- tox.ini | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 193a7eef..1ff7bdd5 100644 --- a/tox.ini +++ b/tox.ini @@ -56,7 +56,9 @@ commands= allowlist_externals= /bin/bash /usr/bin/bash -commands = bash -exc "cd dotbot/frontend && npm run lint" +commands = + bash -exc "cd dotbot/frontend && npm run lint" + bash -exc "cd dotbot/console-web && npm run lint" # pin_code env removed: dotbot/pin_code_ui never existed in the tree — # silent dead config flagged in workspace AGENTS.md. From efb0122f2129fc68739d10a1a1bf6c8e8e33ecf0 Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Fri, 10 Jul 2026 09:16:19 +0200 Subject: [PATCH 24/27] pyproject: ship the console-web app in sdist and wheel AI-assisted: Claude Fable 5 --- .gitignore | 1 + pyproject.toml | 7 +++++++ utils/hooks/pydotbot_utils.py | 11 +++++++++++ utils/hooks/sdist.py | 3 ++- 4 files changed, 21 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index de2230ff..b95a706b 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ *.log *.egg-info/ .coverage +coverage/ .tox/ dist/ node_modules/ diff --git a/pyproject.toml b/pyproject.toml index 20f96104..0c3c680c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,13 +7,20 @@ build-backend = "hatchling.build" [tool.hatch.build] include = [ "dotbot/frontend/*", + "dotbot/console-web/*", "dotbot/*.toml", "*.py" ] exclude = [ "dotbot/frontend/node_modules", + "dotbot/console-web/node_modules", "utils/" ] +# The console build output is gitignored (dist/); artifacts overrides the +# VCS-ignore so the built app ships in the packages. +artifacts = [ + "dotbot/console-web/dist", +] [tool.hatch.metadata] allow-direct-references = true diff --git a/utils/hooks/pydotbot_utils.py b/utils/hooks/pydotbot_utils.py index da126208..d0a97d59 100644 --- a/utils/hooks/pydotbot_utils.py +++ b/utils/hooks/pydotbot_utils.py @@ -23,3 +23,14 @@ def build_frontend(root): print("Building React frontend application...") subprocess.run(shlex.split(NPM_INSTALL_CMD), cwd=frontend_dir, check=True) subprocess.run(shlex.split(NPM_BUILD_CMD), cwd=frontend_dir, check=True) + + +def build_console(root): + """Builds the console web application.""" + console_dir = os.path.join(root, "dotbot", "console-web") + os.makedirs(os.path.join(console_dir, "dist"), exist_ok=True) + + if sys.platform != "win32": + print("Building console web application...") + subprocess.run(shlex.split(NPM_INSTALL_CMD), cwd=console_dir, check=True) + subprocess.run(shlex.split(NPM_BUILD_CMD), cwd=console_dir, check=True) diff --git a/utils/hooks/sdist.py b/utils/hooks/sdist.py index aea0a73a..b0ebfed5 100644 --- a/utils/hooks/sdist.py +++ b/utils/hooks/sdist.py @@ -13,7 +13,7 @@ from hatchling.builders.hooks.plugin.interface import BuildHookInterface sys.path.append(os.path.dirname(__file__)) -from pydotbot_utils import build_frontend # noqa: E402 +from pydotbot_utils import build_console, build_frontend # noqa: E402 class CustomBuildHook(BuildHookInterface): @@ -24,3 +24,4 @@ def initialize(self, _, __): if os.getenv("SKIP_SDIST_HOOK") is not None: return build_frontend(self.root) + build_console(self.root) From 7b39dfc3a227c3f4121dfd1a2e44beb0478c3cb2 Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Fri, 10 Jul 2026 09:32:49 +0200 Subject: [PATCH 25/27] dotbot/server: black reformat AI-assisted: Claude Fable 5 --- dotbot/server.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/dotbot/server.py b/dotbot/server.py index 643b41ba..5884018d 100644 --- a/dotbot/server.py +++ b/dotbot/server.py @@ -431,9 +431,7 @@ async def cleanup(): # with the classic frontend while it matures towards replacing it. CONSOLE_DIR = os.path.join(os.path.dirname(__file__), "console-web", "dist") if os.path.isdir(CONSOLE_DIR): - api.mount( - "/console", StaticFiles(directory=CONSOLE_DIR, html=True), name="console" - ) + api.mount("/console", StaticFiles(directory=CONSOLE_DIR, html=True), name="console") else: LOGGER.info( "Console build not found at %s; /console will be unavailable. " From 37dfd4beff66b89d9affa4bc62a24869e34774f5 Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Fri, 10 Jul 2026 09:32:49 +0200 Subject: [PATCH 26/27] dotbot/console-web: black reformat AI-assisted: Claude Fable 5 --- dotbot/console-web/dev/fake_swarmit_server.py | 49 +++++++++++++++---- 1 file changed, 39 insertions(+), 10 deletions(-) diff --git a/dotbot/console-web/dev/fake_swarmit_server.py b/dotbot/console-web/dev/fake_swarmit_server.py index c7eed8d1..b1426a3d 100644 --- a/dotbot/console-web/dev/fake_swarmit_server.py +++ b/dotbot/console-web/dev/fake_swarmit_server.py @@ -56,14 +56,22 @@ def push_log(level: str, message: str) -> None: global _event_seq _event_seq += 1 events.append( - {"id": _event_seq, "type": "log_event", "level": level, "message": message, "ts": time.time()} + { + "id": _event_seq, + "type": "log_event", + "level": level, + "message": message, + "ts": time.time(), + } ) del events[:-200] def fetch_dotbots() -> list: try: - with urllib.request.urlopen(f"{settings['controller']}/controller/dotbots", timeout=2) as res: + with urllib.request.urlopen( + f"{settings['controller']}/controller/dotbots", timeout=2 + ) as res: return json.loads(res.read()) except Exception: return [] @@ -75,7 +83,10 @@ def known_devices() -> list: def state_of(addr: str) -> dict: if addr not in states: - states[addr] = {"status": "Bootloader" if addr in GHOSTS else "Running", "progress": None} + states[addr] = { + "status": "Bootloader" if addr in GHOSTS else "Running", + "progress": None, + } return states[addr] @@ -138,7 +149,9 @@ async def transition(devices: list, via: str, to: str, delay: float) -> None: @app.post("/start") async def start(request: Request): - body = await request.json() if int(request.headers.get("content-length") or 0) else {} + body = ( + await request.json() if int(request.headers.get("content-length") or 0) else {} + ) devices = resolve_devices(body.get("devices")) for a in devices: state_of(a)["status"] = "Running" @@ -148,7 +161,9 @@ async def start(request: Request): @app.post("/stop") async def stop(request: Request): - body = await request.json() if int(request.headers.get("content-length") or 0) else {} + body = ( + await request.json() if int(request.headers.get("content-length") or 0) else {} + ) devices = resolve_devices(body.get("devices")) push_log("warn", f"stopping · {len(devices)} device(s)") asyncio.create_task(transition(devices, "Stopping", "Bootloader", 1.2)) @@ -157,8 +172,12 @@ async def stop(request: Request): @app.post("/reset") async def reset(request: Request): - body = await request.json() if int(request.headers.get("content-length") or 0) else {} - devices = resolve_devices(body.get("devices") or list(body.get("locations", {}) or {})) + body = ( + await request.json() if int(request.headers.get("content-length") or 0) else {} + ) + devices = resolve_devices( + body.get("devices") or list(body.get("locations", {}) or {}) + ) push_log("warn", f"resetting · {len(devices)} device(s)") asyncio.create_task(transition(devices, "Resetting", "Bootloader", 1.6)) return {"result": "ok", "devices": devices} @@ -187,7 +206,9 @@ async def flash_events(devices: list, fw_len: int): continue p = state_of(a)["progress"] p["acked"] = min(p["total"], p["acked"] + CHUNKS_PER_TICK) - yield _sse({"type": "chunk", "addr": a, "acked": p["acked"], "total": p["total"]}) + yield _sse( + {"type": "chunk", "addr": a, "acked": p["acked"], "total": p["total"]} + ) if p["acked"] >= p["total"]: done.add(a) st = state_of(a) @@ -205,7 +226,13 @@ async def flash_events(devices: list, fw_len: int): } ) push_log("ok", "flash complete · all devices") - yield _sse({"type": "complete", "all_success": True, "elapsed_s": TOTAL_CHUNKS / CHUNKS_PER_TICK * TICK_S}) + yield _sse( + { + "type": "complete", + "all_success": True, + "elapsed_s": TOTAL_CHUNKS / CHUNKS_PER_TICK * TICK_S, + } + ) @app.post("/flash/stream") @@ -213,7 +240,9 @@ async def flash_stream(request: Request): body = await request.json() devices = resolve_devices(body.get("devices")) fw_len = len(body.get("firmware_b64", "")) * 3 // 4 - return StreamingResponse(flash_events(devices, fw_len), media_type="text/event-stream") + return StreamingResponse( + flash_events(devices, fw_len), media_type="text/event-stream" + ) @app.post("/flash") From 7912b450d3001ecb4d0654bb24e49e7b2b97010b Mon Sep 17 00:00:00 2001 From: Arcko5 Date: Tue, 4 Aug 2026 15:14:55 +0400 Subject: [PATCH 27/27] fix(console-web): interpolate bot positions with adaptive duration The bot marker used a fixed CSS transition (left/top .2s linear). Position updates arrive at whatever rate the source reports them (~20Hz in simulation, sparser and irregular from real LH2 hardware), so the fixed 200ms duration is either too short relative to the gap between updates (the bot glides for 200ms then holds still until the next update, producing visible stutter) or too long (a fast update interrupts the transition already in flight). The old frontend has no transition at all and snaps instantly, which is the same defect in its more visible form. Add useSmoothPositions: a small per-bot animation state driven by requestAnimationFrame, using the previously observed update interval as the next animation's duration (clamped to [60ms, 600ms]) instead of a constant. A jump larger than 35% of the arena diagonal in one update is treated as a teleport and rendered instantly rather than animated across the whole arena. MapView now reads bot render position from this hook instead of the raw prop, and the CSS transition is removed (redundant with per-frame interpolation). Verified: vitest (7 new cases covering first-sight snap, in-flight retarget, duration clamping, teleport detection), tsc build, eslint, and a manual pass against the running simulator + vite dev stack. AI-assisted: Claude Sonnet 5 --- dotbot/console-web/src/MapView.tsx | 7 +- .../src/useSmoothPositions.test.ts | 66 +++++++++++ dotbot/console-web/src/useSmoothPositions.ts | 111 ++++++++++++++++++ 3 files changed, 182 insertions(+), 2 deletions(-) create mode 100644 dotbot/console-web/src/useSmoothPositions.test.ts create mode 100644 dotbot/console-web/src/useSmoothPositions.ts diff --git a/dotbot/console-web/src/MapView.tsx b/dotbot/console-web/src/MapView.tsx index b98a75e9..55c75776 100644 --- a/dotbot/console-web/src/MapView.tsx +++ b/dotbot/console-web/src/MapView.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useMemo, useRef, useState } from "react"; import { LH2Position, MapSize, UnifiedBot } from "./types"; +import { useSmoothPositions } from "./useSmoothPositions"; // Layer set mirrors the v1 design (Battery Bars / Waypoints / HotSpots / // DotBots / Real-scale bots); Trails is our addition on top. @@ -72,6 +73,9 @@ export const MapView: React.FC = (props) => { const marqueeRef = useRef<{ additive: boolean } | null>(null); const geomRef = useRef({ w: 1000, h: 600, side: 600 }); + const mapDiagonal = Math.hypot(props.mapSize.width, props.mapSize.height); + const smoothPositions = useSmoothPositions(props.bots, mapDiagonal); + const [side, setSide] = useState(600); const onGeomRef = useRef(props.onGeom); onGeomRef.current = props.onGeom; @@ -319,7 +323,7 @@ export const MapView: React.FC = (props) => { props.bots .filter((b) => b.position) .map((b) => { - const q = pctPos(b.position!); + const q = pctPos(smoothPositions.get(b.id) ?? b.position!); const selected = props.selection.has(b.id); const hovered = hoverId === b.id; const led = ledCss(b); @@ -352,7 +356,6 @@ export const MapView: React.FC = (props) => { left: `${q.left}%`, top: `${q.top}%`, transform: `translate(-50%, -50%) scale(${gscale})`, - transition: "left .2s linear, top .2s linear", cursor: "pointer", zIndex: selected ? 6 : 2, width: 0, diff --git a/dotbot/console-web/src/useSmoothPositions.test.ts b/dotbot/console-web/src/useSmoothPositions.test.ts new file mode 100644 index 00000000..492ba7c4 --- /dev/null +++ b/dotbot/console-web/src/useSmoothPositions.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; + +import { + MAX_DURATION_MS, + MIN_DURATION_MS, + lerpPos, + nextPosState, + positionAt, +} from "./useSmoothPositions"; + +const MAP_DIAGONAL = Math.hypot(2000, 2000); + +describe("lerpPos", () => { + it("interpolates linearly between two points", () => { + expect(lerpPos({ x: 0, y: 0 }, { x: 100, y: 200 }, 0.5)).toEqual({ x: 50, y: 100 }); + }); +}); + +describe("nextPosState", () => { + it("snaps instantly on first sight of a bot (no prior state)", () => { + const s = nextPosState(undefined, { x: 10, y: 20 }, 1000, MAP_DIAGONAL); + expect(s.duration).toBe(0); + expect(positionAt(s, 1000)).toEqual({ x: 10, y: 20 }); + }); + + it("keeps the previous state when the target has not changed", () => { + const s0 = nextPosState(undefined, { x: 10, y: 20 }, 1000, MAP_DIAGONAL); + const s1 = nextPosState(s0, { x: 10, y: 20 }, 1200, MAP_DIAGONAL); + expect(s1).toBe(s0); + }); + + it("animates from the in-flight interpolated position, not the last target", () => { + // t=0 -> (0,0); update at t=0 sets target (100,0), duration 200ms. + const s0 = nextPosState(undefined, { x: 0, y: 0 }, 0, MAP_DIAGONAL); + const s1 = { ...nextPosState(s0, { x: 100, y: 0 }, 0, MAP_DIAGONAL), duration: 200 }; + // halfway through that transition (t=100ms), a new update arrives. + const s2 = nextPosState(s1, { x: 100, y: 50 }, 100, MAP_DIAGONAL); + expect(s2.from).toEqual({ x: 50, y: 0 }); // interpolated, not (100, 0) + expect(s2.to).toEqual({ x: 100, y: 50 }); + }); + + it("uses the observed update interval as the next animation duration, clamped", () => { + const s0 = nextPosState(undefined, { x: 0, y: 0 }, 0, MAP_DIAGONAL); + const s1 = nextPosState(s0, { x: 10, y: 0 }, 30, MAP_DIAGONAL); // 30ms gap -> clamped up + expect(s1.duration).toBe(MIN_DURATION_MS); + const s2 = nextPosState(s1, { x: 20, y: 0 }, 30 + 5000, MAP_DIAGONAL); // 5s gap -> clamped down + expect(s2.duration).toBe(MAX_DURATION_MS); + const s3 = nextPosState(s2, { x: 30, y: 0 }, 30 + 5000 + 250, MAP_DIAGONAL); // 250ms gap -> as-is + expect(s3.duration).toBe(250); + }); + + it("treats a large jump as a teleport: instant, no animation", () => { + const s0 = nextPosState(undefined, { x: 0, y: 0 }, 0, MAP_DIAGONAL); + const s1 = nextPosState(s0, { x: 1900, y: 1900 }, 100, MAP_DIAGONAL); + expect(s1.duration).toBe(0); + expect(positionAt(s1, 100)).toEqual({ x: 1900, y: 1900 }); + }); +}); + +describe("positionAt", () => { + it("clamps at the target once the duration has elapsed", () => { + const s = nextPosState(nextPosState(undefined, { x: 0, y: 0 }, 0, MAP_DIAGONAL), { x: 100, y: 0 }, 0, MAP_DIAGONAL); + const withDuration = { ...s, duration: 200 }; + expect(positionAt(withDuration, 500)).toEqual({ x: 100, y: 0 }); + }); +}); diff --git a/dotbot/console-web/src/useSmoothPositions.ts b/dotbot/console-web/src/useSmoothPositions.ts new file mode 100644 index 00000000..f1c71b04 --- /dev/null +++ b/dotbot/console-web/src/useSmoothPositions.ts @@ -0,0 +1,111 @@ +import { useEffect, useRef, useState } from "react"; + +import { LH2Position, UnifiedBot } from "./types"; + +// Position updates arrive at whatever rate the source reports them: ~20Hz +// from the simulator, sparser and irregular from real LH2 hardware. A fixed +// CSS transition duration is picked independent of that rate, so it is +// either too long (a fast update interrupts the transition already in +// flight, changing its direction/speed mid-motion) or too short relative to +// the interval between updates (the bot glides for the transition duration, +// then holds still until the next update -- the "saccade" reported on both +// simulated and real bots, on the old frontend too, where there was no +// transition at all and every update snapped instantly). +// +// Using the previous observed update interval as the next animation's +// duration keeps the animation running for roughly the whole gap between +// updates, whatever that gap turns out to be, instead of assuming a rate. +export const MIN_DURATION_MS = 60; +export const MAX_DURATION_MS = 600; + +// A jump bigger than this fraction of the arena diagonal in a single update +// is treated as a teleport (bot re-added, position reset by an operator) +// rather than motion, and rendered instantly instead of animated across the +// whole arena. +export const TELEPORT_FRACTION = 0.35; + +export interface PosState { + from: LH2Position; + to: LH2Position; + t0: number; + duration: number; + lastUpdateAt: number; +} + +export function dist(a: LH2Position, b: LH2Position): number { + return Math.hypot(a.x - b.x, a.y - b.y); +} + +export function lerpPos(a: LH2Position, b: LH2Position, t: number): LH2Position { + return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t }; +} + +export function positionAt(state: PosState, now: number): LH2Position { + const t = state.duration > 0 ? Math.min(1, (now - state.t0) / state.duration) : 1; + return lerpPos(state.from, state.to, t); +} + +// Folds one new target position into the previous animation state. `now` +// and `mapDiagonal` are passed in (rather than read from globals) so this +// stays a pure function the hook can be tested through. +export function nextPosState( + prev: PosState | undefined, + target: LH2Position, + now: number, + mapDiagonal: number, +): PosState { + if (!prev) { + return { from: target, to: target, t0: now, duration: 0, lastUpdateAt: now }; + } + if (prev.to.x === target.x && prev.to.y === target.y) return prev; + + const teleport = dist(prev.to, target) > mapDiagonal * TELEPORT_FRACTION; + const interval = now - prev.lastUpdateAt; + const current = positionAt(prev, now); + return { + from: teleport ? target : current, + to: target, + t0: now, + duration: teleport ? 0 : Math.max(MIN_DURATION_MS, Math.min(MAX_DURATION_MS, interval)), + lastUpdateAt: now, + }; +} + +// Smoothed per-bot positions, keyed by bot id. Only bots with a known +// position are present. Re-renders on every animation frame while at least +// one bot is mid-transition; callers read from the returned map instead of +// `bot.position` for the animated glyph, and keep using `bot.position` +// directly for anything that should update instantly (trails, waypoints). +export function useSmoothPositions( + bots: UnifiedBot[], + mapDiagonal: number, +): Map { + const statesRef = useRef>(new Map()); + const [, tick] = useState(0); + + useEffect(() => { + const now = performance.now(); + for (const b of bots) { + if (!b.position) continue; + const prev = statesRef.current.get(b.id); + statesRef.current.set(b.id, nextPosState(prev, b.position, now, mapDiagonal)); + } + }, [bots, mapDiagonal]); + + useEffect(() => { + let raf: number; + const loop = () => { + tick((n) => n + 1); + raf = requestAnimationFrame(loop); + }; + raf = requestAnimationFrame(loop); + return () => cancelAnimationFrame(raf); + }, []); + + const now = performance.now(); + const result = new Map(); + for (const [id, state] of statesRef.current) { + result.set(id, positionAt(state, now)); + } + return result; +}