diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000000000000000000000000000000000..c1709971c22830fa68a19d55b41ffed5b5038dd1 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,42 @@ +*.7z filter=lfs diff=lfs merge=lfs -text +*.arrow filter=lfs diff=lfs merge=lfs -text +*.bin filter=lfs diff=lfs merge=lfs -text +*.bz2 filter=lfs diff=lfs merge=lfs -text +*.ckpt filter=lfs diff=lfs merge=lfs -text +*.ftz filter=lfs diff=lfs merge=lfs -text +*.gz filter=lfs diff=lfs merge=lfs -text +*.h5 filter=lfs diff=lfs merge=lfs -text +*.joblib filter=lfs diff=lfs merge=lfs -text +*.lfs.* filter=lfs diff=lfs merge=lfs -text +*.mlmodel filter=lfs diff=lfs merge=lfs -text +*.model filter=lfs diff=lfs merge=lfs -text +*.msgpack filter=lfs diff=lfs merge=lfs -text +*.npy filter=lfs diff=lfs merge=lfs -text +*.npz filter=lfs diff=lfs merge=lfs -text +*.onnx filter=lfs diff=lfs merge=lfs -text +*.ot filter=lfs diff=lfs merge=lfs -text +*.parquet filter=lfs diff=lfs merge=lfs -text +*.pb filter=lfs diff=lfs merge=lfs -text +*.pickle filter=lfs diff=lfs merge=lfs -text +*.pkl filter=lfs diff=lfs merge=lfs -text +*.pt filter=lfs diff=lfs merge=lfs -text +*.pth filter=lfs diff=lfs merge=lfs -text +*.rar filter=lfs diff=lfs merge=lfs -text +*.safetensors filter=lfs diff=lfs merge=lfs -text +saved_model/**/* filter=lfs diff=lfs merge=lfs -text +*.tar.* filter=lfs diff=lfs merge=lfs -text +*.tar filter=lfs diff=lfs merge=lfs -text +*.tflite filter=lfs diff=lfs merge=lfs -text +*.tgz filter=lfs diff=lfs merge=lfs -text +*.wasm filter=lfs diff=lfs merge=lfs -text +*.xz filter=lfs diff=lfs merge=lfs -text +*.zip filter=lfs diff=lfs merge=lfs -text +*.zst filter=lfs diff=lfs merge=lfs -text +*tfevents* filter=lfs diff=lfs merge=lfs -text +*.stl filter=lfs diff=lfs merge=lfs -text +*.lockb filter=lfs diff=lfs merge=lfs -text + +# Plain binary (overrides LFS): installing from git skips LFS, and the build copies these into the wheel. +# sync_space.yml puts them back in LFS for the Space. +public/so-101-urdf/meshes/*.stl -filter -diff -merge +public/so-101-urdf/meshes/*.stl filter=lfs diff=lfs merge=lfs -text diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..7fef8a294454a94979716efc275a67bfe4368206 --- /dev/null +++ b/.gitignore @@ -0,0 +1,49 @@ +# Dependencies +/node_modules +/.pnp +.pnp.js + +# Testing +/coverage + +# Production +/build +/dist + +# Misc +.DS_Store +.env +.env.local +.env.development.local +.env.test.local +.env.production.local +.env*.local + +# Debug logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# TypeScript +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..25270727604c65be8fa277ecb398c35577133370 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,38 @@ +# Vite 8 requires Node ^20.19.0 or >=22.12.0. +FROM node:22-alpine + +# Use the existing node user (usually UID 1000) +# Set up environment variables for the node user +ENV HOME=/home/node \ + PATH=/home/node/.local/bin:$PATH + +# Create and set up app directory owned by node user +# Go to user's home directory first to ensure it exists +WORKDIR $HOME +RUN mkdir -p $HOME/app && \ + chown -R node:node $HOME/app && \ + chmod -R 755 $HOME/app # Set initial permissions +WORKDIR $HOME/app + +# Switch to the node user +USER node + +# Copy package files (owned by node) +COPY --chown=node:node package*.json ./ + +# Install dependencies straight from the committed lockfile. `npm ci` honours +# the `resolved` URLs in package-lock.json and ignores the builder's configured +# registry, so the install can't be derailed by a slow/incomplete npm mirror. +RUN npm ci + +# Copy the entire viewer directory (owned by node) +COPY --chown=node:node . . + +# Build the application +RUN npm run build + +# Expose port +EXPOSE 7860 + +# Start the application +CMD ["npm", "run", "preview", "--", "--port", "7860", "--host"] diff --git a/HTTPS_SETUP.md b/HTTPS_SETUP.md new file mode 100644 index 0000000000000000000000000000000000000000..5e0cbd72e84308bc2258bd74f414c208e143e1e2 --- /dev/null +++ b/HTTPS_SETUP.md @@ -0,0 +1,219 @@ +# HTTPS Setup for Local Development + +This guide explains how to enable HTTPS for the frontend development server to ensure camera access works properly from mobile devices. + +## Why HTTPS is Required + +Modern browsers require HTTPS for accessing sensitive APIs like `getUserMedia()` (camera access), especially when: + +- Accessing from a different device (e.g., phone connecting to laptop's dev server) +- The origin is not `localhost` or `127.0.0.1` + +Without HTTPS, camera access will be blocked by the browser's security policies. + +## Automatic Setup + +We've already set up HTTPS for you! The certificates are generated and the development server is configured to use them. + +### Quick Start + +1. **Start the development server:** + + ```bash + npm run dev + ``` + +2. **Access from your computer:** + + - `https://localhost:8080` + - `https://127.0.0.1:8080` + +3. **Access from your phone:** + - `https://192.168.1.103:8080` (or your current local IP) + +## Manual Setup (if needed) + +If you need to regenerate certificates or set up on a new machine: + +```bash +# Run our setup script +./scripts/setup-https.sh +``` + +Or manually: + +```bash +# Install mkcert +brew install mkcert # macOS +# For other platforms: https://github.com/FiloSottile/mkcert#installation + +# Install local CA +mkcert -install + +# Generate certificates +mkdir -p certs +cd certs +mkcert localhost 127.0.0.1 $(ipconfig getifaddr en0) ::1 +cd .. + +# Start development server +npm run dev +``` + +## Certificate Details + +- **Location:** `./certs/` +- **Files:** + - `localhost+3.pem` (certificate) + - `localhost+3-key.pem` (private key) +- **Validity:** 3 months +- **Domains:** localhost, 127.0.0.1, your local IP, IPv6 localhost + +## Mobile Device Setup + +### For Phone Access: + +1. **Ensure same WiFi network:** Your phone and development machine must be on the same WiFi network + +2. **Find your local IP:** + + ```bash + ipconfig getifaddr en0 # macOS + # or check the console output when starting the dev server + ``` + +3. **Access from phone:** + + - Open Safari (iOS) or Chrome (Android) + - Navigate to `https://YOUR_LOCAL_IP:8080` + - You may see a security warning - this is normal for local certificates + +4. **Accept the certificate:** + + - **iOS Safari:** Tap "Advanced" → "Proceed to website" + - **Android Chrome:** Tap "Advanced" → "Proceed to site (unsafe)" + +5. **Test camera access:** + - Go to the recording page OR use our camera test page: `https://YOUR_LOCAL_IP:8080/test-camera.html` + - Try to add a camera or click "Start Camera" + - The browser should prompt for camera permission + - Grant permission to test `getUserMedia()` functionality + +## Troubleshooting + +### Certificate Issues + +If you see certificate errors: + +```bash +# Regenerate certificates +rm -rf certs/ +./scripts/setup-https.sh +``` + +### IP Address Changes + +If your local IP changes (common with DHCP): + +```bash +# Check current IP +ipconfig getifaddr en0 + +# Regenerate certificates with new IP +rm -rf certs/ +./scripts/setup-https.sh +``` + +### Port Already in Use + +If port 8080 is busy: + +```bash +# Check what's using the port +lsof -ti:8080 + +# Kill the process if needed +kill -9 $(lsof -ti:8080) +``` + +### Browser Cache Issues + +If you're having issues after certificate changes: + +1. Clear browser cache and cookies for localhost +2. Restart the browser +3. Try incognito/private mode + +## Network Address Detection + +The app automatically detects the appropriate URL for phone access: + +- **Localhost access:** Automatically converts to network IP for QR codes +- **Network access:** Uses current URL as-is +- **Domain access:** Works with existing SSL certificates + +You can see the detected address in: + +- Browser console logs +- Camera configuration UI (blue info box) + +## Security Notes + +- These certificates are only trusted on your local machine +- They're perfect for development but should never be used in production +- The private key is stored locally and should not be shared +- Certificates expire after 3 months for security + +## Production Deployment + +For production: + +- Use a proper SSL certificate from a trusted CA +- Configure your reverse proxy (nginx, Apache) or hosting platform +- Ensure HTTPS is enforced across your entire application + +## Verification + +To verify HTTPS is working: + +1. **Check the URL bar:** Should show `https://` with a lock icon +2. **Check console:** Network address detection should show HTTPS URLs +3. **Test camera access:** `getUserMedia()` should work without security errors +4. **Check from phone:** Camera permissions should be available + +### Quick Test Page + +We've included a dedicated camera test page for easy verification: + +**Desktop:** `https://localhost:8080/test-camera.html` +**Phone:** `https://192.168.1.103:8080/test-camera.html` + +This page will: + +- ✅ Verify HTTPS and secure context +- ✅ Test camera permissions and `getUserMedia()` +- ✅ Display real-time video stream +- ✅ Show detailed connection information +- ✅ Provide troubleshooting guidance + +### Expected Results + +**✅ Success indicators:** + +- Green protocol check: "Camera access should work!" +- Camera permission prompt appears +- Video stream displays without errors +- No console security warnings + +**❌ Failure indicators:** + +- Red protocol check: "Camera access may be blocked" +- `NotAllowedError` or `NotSecureError` in console +- No camera permission prompt +- "This site is not secure" warnings + +## Additional Resources + +- [mkcert GitHub Repository](https://github.com/FiloSottile/mkcert) +- [MDN: getUserMedia()](https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia) +- [Chrome Developer: HTTPS for Localhost](https://web.dev/how-to-use-local-https/) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..d645695673349e3947e8e5ae42332d0ac3164cd7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..6243b501b4f98b07d866677de3fd7b5d5fd20624 --- /dev/null +++ b/README.md @@ -0,0 +1,20 @@ +--- +title: LeLab +emoji: ⚡ +colorFrom: yellow +colorTo: red +sdk: docker +app_port: 7860 +pinned: false +short_description: Simple Interface to use LeRobot +--- + +Screenshot 2025-06-19 at 00 23 55 + +# LeLab official Space repository + +If you've used [LeLab](https://huggingface.co/spaces/lerobot/LeLab) and want to contribute or found a bug, this is the place to be. This repo is directly hooked up to the Hugging Face space. + +Here's the equivalent [backend](https://github.com/huggingface/leLab) that keeps the FastAPI server you run to actually wrap the LeRobot library. + +We'll be updating this README shortly. diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/components.json b/components.json new file mode 100644 index 0000000000000000000000000000000000000000..62e101166a31ade477811bd31f6be1ae44d45423 --- /dev/null +++ b/components.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "src/index.css", + "baseColor": "slate", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + } +} diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000000000000000000000000000000000000..e67846f70fbbe40407fc84875913595ab31c4a47 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,29 @@ +import js from "@eslint/js"; +import globals from "globals"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import tseslint from "typescript-eslint"; + +export default tseslint.config( + { ignores: ["dist"] }, + { + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ["**/*.{ts,tsx}"], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + plugins: { + "react-hooks": reactHooks, + "react-refresh": reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + "react-refresh/only-export-components": [ + "warn", + { allowConstantExport: true }, + ], + "@typescript-eslint/no-unused-vars": "off", + }, + } +); diff --git a/index.html b/index.html new file mode 100644 index 0000000000000000000000000000000000000000..99bb4821df397183531ebde015006e4f65f1c3c2 --- /dev/null +++ b/index.html @@ -0,0 +1,19 @@ + + + + + + LeLab + + + + + + + + + +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000000000000000000000000000000000000..367e9e2bd463a02b82bf36568ec694f8a5692c82 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,8426 @@ +{ + "name": "vite_react_shadcn_ts", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "vite_react_shadcn_ts", + "version": "0.0.0", + "dependencies": { + "@hookform/resolvers": "^3.9.0", + "@radix-ui/react-accordion": "^1.2.0", + "@radix-ui/react-alert-dialog": "^1.1.1", + "@radix-ui/react-aspect-ratio": "^1.1.0", + "@radix-ui/react-avatar": "^1.1.0", + "@radix-ui/react-checkbox": "^1.1.1", + "@radix-ui/react-collapsible": "^1.1.0", + "@radix-ui/react-context-menu": "^2.2.1", + "@radix-ui/react-dialog": "^1.1.2", + "@radix-ui/react-dropdown-menu": "^2.1.1", + "@radix-ui/react-hover-card": "^1.1.1", + "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-menubar": "^1.1.1", + "@radix-ui/react-navigation-menu": "^1.2.0", + "@radix-ui/react-popover": "^1.1.1", + "@radix-ui/react-progress": "^1.1.0", + "@radix-ui/react-radio-group": "^1.2.0", + "@radix-ui/react-scroll-area": "^1.1.0", + "@radix-ui/react-select": "^2.1.1", + "@radix-ui/react-separator": "^1.1.0", + "@radix-ui/react-slider": "^1.2.0", + "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-switch": "^1.1.0", + "@radix-ui/react-tabs": "^1.1.0", + "@radix-ui/react-toast": "^1.2.1", + "@radix-ui/react-toggle": "^1.1.0", + "@radix-ui/react-toggle-group": "^1.1.0", + "@radix-ui/react-tooltip": "^1.1.4", + "@react-three/drei": "^9.122.0", + "@react-three/fiber": "^8.18.0", + "@tanstack/react-query": "^5.56.2", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.0.0", + "date-fns": "^3.6.0", + "embla-carousel-react": "^8.3.0", + "input-otp": "^1.2.4", + "jszip": "^3.10.1", + "lucide-react": "^0.462.0", + "next-themes": "^0.3.0", + "react": "^18.3.1", + "react-day-picker": "^8.10.1", + "react-dom": "^18.3.1", + "react-hook-form": "^7.53.0", + "react-resizable-panels": "^2.1.3", + "react-router-dom": "^7.18.3", + "recharts": "^2.12.7", + "sonner": "^1.5.0", + "tailwind-merge": "^2.5.2", + "tailwindcss-animate": "^1.0.7", + "three": "^0.177.0", + "urdf-loader": "^0.12.6", + "vaul": "^0.9.3", + "zod": "^3.23.8" + }, + "devDependencies": { + "@eslint/js": "^9.9.0", + "@tailwindcss/typography": "^0.5.15", + "@types/node": "^22.5.5", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react-swc": "^4.3.1", + "autoprefixer": "^10.4.20", + "eslint": "^9.9.0", + "eslint-plugin-react-hooks": "^5.1.0-rc.0", + "eslint-plugin-react-refresh": "^0.4.9", + "globals": "^15.9.0", + "jsdom": "^28.1.0", + "lovable-tagger": "^1.3.0", + "postcss": "^8.5.25", + "tailwindcss": "^3.4.11", + "typescript": "^5.5.3", + "typescript-eslint": "^8.0.1", + "vite": "^8.0.16", + "vitest": "^5.0.1" + } + }, + "node_modules/@acemir/cssom": { + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", + "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.6" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", + "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.2.tgz", + "integrity": "sha512-3QKjR/vxyjcSXBLgb6lP0S3MGdvwbmqSsvLPbYdVORqPDc8FX1HAJ0Spk38bxaRXgvENTA47tlhhbb5Z2e8hEg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.1", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.12.tgz", + "integrity": "sha512-3vLQK+dXxhBMR2Wx99PTCifE+vHtW2ndZWyla8yK813ev6oGhyn8Lja8jCyGAWTJ+LEYZK7EVtJxrDj8ztevJw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "license": "Apache-2.0" + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "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.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.6" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@hookform/resolvers": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.10.0.tgz", + "integrity": "sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag==", + "license": "MIT", + "peerDependencies": { + "react-hook-form": "^7.0.0" + } + }, + "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/@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==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@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==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "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==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mediapipe/tasks-vision": { + "version": "0.10.17", + "resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.17.tgz", + "integrity": "sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg==", + "license": "Apache-2.0" + }, + "node_modules/@monogrid/gainmap-js": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@monogrid/gainmap-js/-/gainmap-js-3.4.0.tgz", + "integrity": "sha512-2Z0FATFHaoYJ8b+Y4y4Hgfn3FRFwuU5zRrk+9dFWp4uGAdHGqVEdP7HP+gLA3X469KXHmfupJaUbKo1b/aDKIg==", + "license": "MIT", + "dependencies": { + "promise-worker-transferable": "^1.0.4" + }, + "peerDependencies": { + "three": ">= 0.159.0" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@radix-ui/number": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", + "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==", + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-accordion": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz", + "integrity": "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collapsible": "1.1.12", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz", + "integrity": "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dialog": "1.1.15", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-aspect-ratio": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.8.tgz", + "integrity": "sha512-5nZrJTF7gH+e0nZS7/QxFz6tJV4VimhQb1avEgtsJxvvIp5JilL+c58HICsKzPxghdwaDt48hEfPM1au4zGy+w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-aspect-ratio/node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.11.tgz", + "integrity": "sha512-0Qk603AHGV28BOBO34p7IgD5m+V5Sg/YovfayABkoDDBM5d3NCx0Mp4gGrjzLGes1jV5eNOE1r3itqOR33VC6Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.3", + "@radix-ui/react-primitive": "2.1.4", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar/node_modules/@radix-ui/react-context": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.3.tgz", + "integrity": "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar/node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz", + "integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz", + "integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context-menu": { + "version": "2.2.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.16.tgz", + "integrity": "sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz", + "integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.15.tgz", + "integrity": "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.8.tgz", + "integrity": "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", + "integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menubar": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.16.tgz", + "integrity": "sha512-EB1FktTz5xRRi2Er974AUQZWg2yVBb1yjip38/lgwtCVRd3a+maUoGHN/xs9Yv8SY8QwbSEb+YrxGadVWbEutA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-navigation-menu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.14.tgz", + "integrity": "sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz", + "integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.8.tgz", + "integrity": "sha512-+gISHcSPUJ7ktBy9RnTqbdKW78bcGke3t6taawyZ71pio1JewwGSJizycs7rLhGTvMJYCQB1DBK4KQsxs7U8dA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.3", + "@radix-ui/react-primitive": "2.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-context": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.3.tgz", + "integrity": "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz", + "integrity": "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", + "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz", + "integrity": "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz", + "integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.8.tgz", + "integrity": "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slider": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.6.tgz", + "integrity": "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", + "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz", + "integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", + "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.15.tgz", + "integrity": "sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz", + "integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle-group": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.11.tgz", + "integrity": "sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-toggle": "1.1.10", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", + "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-is-hydrated": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.0.tgz", + "integrity": "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.5.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", + "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", + "license": "MIT" + }, + "node_modules/@react-spring/animated": { + "version": "9.7.5", + "resolved": "https://registry.npmjs.org/@react-spring/animated/-/animated-9.7.5.tgz", + "integrity": "sha512-Tqrwz7pIlsSDITzxoLS3n/v/YCUHQdOIKtOJf4yL6kYVSDTSmVK1LI1Q3M/uu2Sx4X3pIWF3xLUhlsA6SPNTNg==", + "license": "MIT", + "dependencies": { + "@react-spring/shared": "~9.7.5", + "@react-spring/types": "~9.7.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@react-spring/core": { + "version": "9.7.5", + "resolved": "https://registry.npmjs.org/@react-spring/core/-/core-9.7.5.tgz", + "integrity": "sha512-rmEqcxRcu7dWh7MnCcMXLvrf6/SDlSokLaLTxiPlAYi11nN3B5oiCUAblO72o+9z/87j2uzxa2Inm8UbLjXA+w==", + "license": "MIT", + "dependencies": { + "@react-spring/animated": "~9.7.5", + "@react-spring/shared": "~9.7.5", + "@react-spring/types": "~9.7.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-spring/donate" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@react-spring/rafz": { + "version": "9.7.5", + "resolved": "https://registry.npmjs.org/@react-spring/rafz/-/rafz-9.7.5.tgz", + "integrity": "sha512-5ZenDQMC48wjUzPAm1EtwQ5Ot3bLIAwwqP2w2owG5KoNdNHpEJV263nGhCeKKmuA3vG2zLLOdu3or6kuDjA6Aw==", + "license": "MIT" + }, + "node_modules/@react-spring/shared": { + "version": "9.7.5", + "resolved": "https://registry.npmjs.org/@react-spring/shared/-/shared-9.7.5.tgz", + "integrity": "sha512-wdtoJrhUeeyD/PP/zo+np2s1Z820Ohr/BbuVYv+3dVLW7WctoiN7std8rISoYoHpUXtbkpesSKuPIw/6U1w1Pw==", + "license": "MIT", + "dependencies": { + "@react-spring/rafz": "~9.7.5", + "@react-spring/types": "~9.7.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@react-spring/three": { + "version": "9.7.5", + "resolved": "https://registry.npmjs.org/@react-spring/three/-/three-9.7.5.tgz", + "integrity": "sha512-RxIsCoQfUqOS3POmhVHa1wdWS0wyHAUway73uRLp3GAL5U2iYVNdnzQsep6M2NZ994BlW8TcKuMtQHUqOsy6WA==", + "license": "MIT", + "dependencies": { + "@react-spring/animated": "~9.7.5", + "@react-spring/core": "~9.7.5", + "@react-spring/shared": "~9.7.5", + "@react-spring/types": "~9.7.5" + }, + "peerDependencies": { + "@react-three/fiber": ">=6.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "three": ">=0.126" + } + }, + "node_modules/@react-spring/types": { + "version": "9.7.5", + "resolved": "https://registry.npmjs.org/@react-spring/types/-/types-9.7.5.tgz", + "integrity": "sha512-HVj7LrZ4ReHWBimBvu2SKND3cDVUPWKLqRTmWe/fNY6o1owGOX0cAHbdPDTMelgBlVbrTKrre6lFkhqGZErK/g==", + "license": "MIT" + }, + "node_modules/@react-three/drei": { + "version": "9.122.0", + "resolved": "https://registry.npmjs.org/@react-three/drei/-/drei-9.122.0.tgz", + "integrity": "sha512-SEO/F/rBCTjlLez7WAlpys+iGe9hty4rNgjZvgkQeXFSiwqD4Hbk/wNHMAbdd8vprO2Aj81mihv4dF5bC7D0CA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mediapipe/tasks-vision": "0.10.17", + "@monogrid/gainmap-js": "^3.0.6", + "@react-spring/three": "~9.7.5", + "@use-gesture/react": "^10.3.1", + "camera-controls": "^2.9.0", + "cross-env": "^7.0.3", + "detect-gpu": "^5.0.56", + "glsl-noise": "^0.0.0", + "hls.js": "^1.5.17", + "maath": "^0.10.8", + "meshline": "^3.3.1", + "react-composer": "^5.0.3", + "stats-gl": "^2.2.8", + "stats.js": "^0.17.0", + "suspend-react": "^0.1.3", + "three-mesh-bvh": "^0.7.8", + "three-stdlib": "^2.35.6", + "troika-three-text": "^0.52.0", + "tunnel-rat": "^0.1.2", + "utility-types": "^3.11.0", + "zustand": "^5.0.1" + }, + "peerDependencies": { + "@react-three/fiber": "^8", + "react": "^18", + "react-dom": "^18", + "three": ">=0.137" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/@react-three/fiber": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-8.18.0.tgz", + "integrity": "sha512-FYZZqD0UUHUswKz3LQl2Z7H24AhD14XGTsIRw3SJaXUxyfVMi+1yiZGmqTcPt/CkPpdU7rrxqcyQ1zJE5DjvIQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.17.8", + "@types/react-reconciler": "^0.26.7", + "@types/webxr": "*", + "base64-js": "^1.5.1", + "buffer": "^6.0.3", + "its-fine": "^1.0.6", + "react-reconciler": "^0.27.0", + "react-use-measure": "^2.1.7", + "scheduler": "^0.21.0", + "suspend-react": "^0.1.3", + "zustand": "^3.7.1" + }, + "peerDependencies": { + "expo": ">=43.0", + "expo-asset": ">=8.4", + "expo-file-system": ">=11.0", + "expo-gl": ">=11.0", + "react": ">=18 <19", + "react-dom": ">=18 <19", + "react-native": ">=0.64", + "three": ">=0.133" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + }, + "expo-asset": { + "optional": true + }, + "expo-file-system": { + "optional": true + }, + "expo-gl": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/@react-three/fiber/node_modules/zustand": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-3.7.2.tgz", + "integrity": "sha512-PIJDIZKtokhof+9+60cpockVOq05sJzHCriyvaLBmEJixseQ1a5Kdov6fWZfWOu5SK9c+FhH1jU0tntLxRJYMA==", + "license": "MIT", + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@swc/core": { + "version": "1.15.40", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.40.tgz", + "integrity": "sha512-2kwzJikRvgtNAG7MwVZY2vEzZjTxKIq5jXOihuSV/8U+Hej8Va22t65aKnJZs3P+NwojZvR8Mf8kyM7O+V8sQg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3", + "@swc/types": "^0.1.26" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/swc" + }, + "optionalDependencies": { + "@swc/core-darwin-arm64": "1.15.40", + "@swc/core-darwin-x64": "1.15.40", + "@swc/core-linux-arm-gnueabihf": "1.15.40", + "@swc/core-linux-arm64-gnu": "1.15.40", + "@swc/core-linux-arm64-musl": "1.15.40", + "@swc/core-linux-ppc64-gnu": "1.15.40", + "@swc/core-linux-s390x-gnu": "1.15.40", + "@swc/core-linux-x64-gnu": "1.15.40", + "@swc/core-linux-x64-musl": "1.15.40", + "@swc/core-win32-arm64-msvc": "1.15.40", + "@swc/core-win32-ia32-msvc": "1.15.40", + "@swc/core-win32-x64-msvc": "1.15.40" + }, + "peerDependencies": { + "@swc/helpers": ">=0.5.17" + }, + "peerDependenciesMeta": { + "@swc/helpers": { + "optional": true + } + } + }, + "node_modules/@swc/core-darwin-arm64": { + "version": "1.15.40", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.40.tgz", + "integrity": "sha512-PaYyclfmQ++77D8ityYvmmVzHv9aG8ROwt2GfG6/ccloy4Hgf80qtOnzb9VYvPsUT7Ty1uhuDRhv3XYpf62qhQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-darwin-x64": { + "version": "1.15.40", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.40.tgz", + "integrity": "sha512-HbbPzvfLBUXjIB1Ezks+//lNUjmLjfyd63XSwprJgrZaXYdm70kohXPJUWdqKZozolFxbPaO+xtBaiUp6BoueA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm-gnueabihf": { + "version": "1.15.40", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.40.tgz", + "integrity": "sha512-SlRZsCjOCPR2LvFs0Ri/Xrx/5o5TCt8vl4gW6mX1hEZOG0a625RxzRHpHdAQNGykmAN/7IeaFAJG+QnNmxlHcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-gnu": { + "version": "1.15.40", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.40.tgz", + "integrity": "sha512-Q8byxJt2fh8CR3EUX6snBpy47AoBVm+In/+Z3rjDHMjC38ZvR9/gtUUNCT0tfrn4EdVsO8/QPi59nxrxvqxvBQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-musl": { + "version": "1.15.40", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.40.tgz", + "integrity": "sha512-4z0MgHU+7M0pZDqBN1El7mFXDI1SBwinfcUkAyA4v8QrhOIUOZltySt2aStQLZGrdXVXM4Y4ylfiTC04ED+MoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-ppc64-gnu": { + "version": "1.15.40", + "resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.40.tgz", + "integrity": "sha512-fLI4iUgeSZu0eRWUXwe6YzPFx9gHbFiPkl8Rp3mJfP8OpNR3nTQCGPvHdDh9xniW7mVvgMY4ni7A4VzqI1KrpA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-s390x-gnu": { + "version": "1.15.40", + "resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.40.tgz", + "integrity": "sha512-YqeKMAb7d4nQSGMJQ454IlaCENpzcDqhvBE9+CPfdnYpnUXxd+BSrB6Xk0YjW8UyoEhUj4p6quATCxbsp6J3jg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-gnu": { + "version": "1.15.40", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.40.tgz", + "integrity": "sha512-7HOuS1iGcme/j/TuL1TfmmLGiMQrjv/GmjyZeydl00FKPtpGXEldwqfI56xgd1YzrzoB2svWjxbGGyQ0TEASxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-musl": { + "version": "1.15.40", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.40.tgz", + "integrity": "sha512-h4kZYHc7dpc9P9u4brRJaS8Pl7tPVHAeiLSzw7T5RfIJgAoSdaCMKzI/2Uay9gFhaw8uyCDl0L5q37r0EpAfIA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-arm64-msvc": { + "version": "1.15.40", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.40.tgz", + "integrity": "sha512-+mQgKZXSj6mV38Zh05QaxSjUDmGP/R2JWlXZTDLSPkDzHU6p3GxN9eeSf5dfyDVU86946fmCvSzyl/ucImx8+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-ia32-msvc": { + "version": "1.15.40", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.40.tgz", + "integrity": "sha512-yvwdPLGd25mcj/mNatjNQ0lZujtQD6psH3v9PNmMb+fSzjbNG8KIDxjFWrcV+fsFVLOkyOmdJsFmX7NAFjVyPw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-x64-msvc": { + "version": "1.15.40", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.40.tgz", + "integrity": "sha512-OXtKsLU1bVtInzzDEAY2sYiF/rl4tvAnLLLpuMp3HzAOQZ5A+i69AKDhA1YLQTaMAqO3vzyYNVAYVRMPtSYD4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@swc/types": { + "version": "0.1.26", + "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.26.tgz", + "integrity": "sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3" + } + }, + "node_modules/@tailwindcss/typography": { + "version": "0.5.19", + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz", + "integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "6.0.10" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.101.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.0.tgz", + "integrity": "sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.0.tgz", + "integrity": "sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/draco3d": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/draco3d/-/draco3d-1.4.10.tgz", + "integrity": "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==", + "license": "MIT" + }, + "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/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": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/offscreencanvas": { + "version": "2019.7.3", + "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz", + "integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==", + "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==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.30", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.30.tgz", + "integrity": "sha512-3ek6mwJL5/VBewBcY4S66cqlCtK3qi4WIq37Z0m/NHw1hjhI7274Mx1qz/+ggSzyBCOEf7eHjBN6INjPAWYfYw==", + "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==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/react-reconciler": { + "version": "0.26.7", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.26.7.tgz", + "integrity": "sha512-mBDYl8x+oyPX/VBb3E638N0B7xG+SPk/EAMcVPeexqus/5aTpTphQi0curhhshOqRrc9t6OPoJfEUkbymse/lQ==", + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.184.1", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.184.1.tgz", + "integrity": "sha512-6q4VdiqVsrTRqmk62/BnlcAvIrnDM0zf2ZDVKI5kZiniWrSaOHaQzmbp+BNzoggc/8tgW412pL//wZIxu2PPTA==", + "license": "MIT", + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": ">=0.5.17", + "fflate": "~0.8.2", + "meshoptimizer": "~1.1.1" + } + }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.1.tgz", + "integrity": "sha512-JQ4S5GB0tfjO8BuJ4fcX+HodkzJjYBV+7OJ+wLygaX7OGQ7FudyHL4NSCA6ob+w3Yn+5MkKIozOwQhXeM7opVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.60.1", + "@typescript-eslint/type-utils": "8.60.1", + "@typescript-eslint/utils": "8.60.1", + "@typescript-eslint/visitor-keys": "8.60.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "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-eslint/parser": "^8.60.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "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": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.1.tgz", + "integrity": "sha512-A0M6ua6H252bVjPvvtSgl2QA4+ET9S5Mtkb2GDyTxIhH/C4qDItT7RQNO5PhMC6NXGYXOR9dIalcDDgBKT7oFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.60.1", + "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/typescript-estree": "8.60.1", + "@typescript-eslint/visitor-keys": "8.60.1", + "debug": "^4.4.3" + }, + "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/project-service": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.1.tgz", + "integrity": "sha512-eXkTH2bxmXlqD1RnOPmLZ9ZM9D3VwSx04JOwBnP9RQ+yUA5a2Mu7SfW8uaV2Aon53NJzZlZYuX7tn91Izf+xaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.60.1", + "@typescript-eslint/types": "^8.60.1", + "debug": "^4.4.3" + }, + "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/scope-manager": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.60.1.tgz", + "integrity": "sha512-gvI5OQoptnxQnchOirukCuQ55svJSTuD/4k5+pC267xyBtYry748R9/c3tYUzb/iE6RZfllRz2lVulLCHkTm4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/visitor-keys": "8.60.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.1.tgz", + "integrity": "sha512-nh8w4qAteiKuZu3pSSzG/yGKpw0OlkrKnzFmbVRenKaD4qc+7i1GrmZaLVkr8rk4uipiPGMOW4YsM6WmKZ5CvA==", + "dev": true, + "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.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.60.1.tgz", + "integrity": "sha512-sdwTrpjosW7ANQYJ39ZBF1ZyEMEGVB2UsikrserVM/30a/F1dTLnu9bGxEdosugyu5caigjLrR2qiD11asjI1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/typescript-estree": "8.60.1", + "@typescript-eslint/utils": "8.60.1", + "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.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.1.tgz", + "integrity": "sha512-4h0tY8ppCkdCzcrl2YM5M3my0xsE1Tf8om3owEu5oPWmXwkKRmk0j0LGDzYBGUcAlesEbxBhazqu/K4cu3Ug7w==", + "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.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.1.tgz", + "integrity": "sha512-alpRkfG8hlVE5kdJW2GkfgDgXxold3e8e4l6EnmhRmRLbekgAPCCGDVD++sABy9FcgPFroq+uFcCSM1vR57Cew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.60.1", + "@typescript-eslint/tsconfig-utils": "8.60.1", + "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/visitor-keys": "8.60.1", + "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/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/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/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/@typescript-eslint/utils": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.60.1.tgz", + "integrity": "sha512-h2MPBLoNtjc3qZWfY3Tl51yPorQ2McHn8pJfcMNTcIvrrZrr90Ykffit0yjrPFWQcRcUxzH20+6OcVdW4yHtUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.60.1", + "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/typescript-estree": "8.60.1" + }, + "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.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.1.tgz", + "integrity": "sha512-EbGRQg4FhrmwLodl+t3JNAnXHWVr9Vp+Zl1QBZVPY4ByfkzIT8cX3K6QWODHtkIZqqJVEWvhHSx3v5PDHsaQag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.60.1", + "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/@typescript-eslint/visitor-keys/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/@use-gesture/core": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz", + "integrity": "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==", + "license": "MIT" + }, + "node_modules/@use-gesture/react": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/react/-/react-10.3.1.tgz", + "integrity": "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==", + "license": "MIT", + "dependencies": { + "@use-gesture/core": "10.3.1" + }, + "peerDependencies": { + "react": ">= 16.8.0" + } + }, + "node_modules/@vitejs/plugin-react-swc": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-4.3.1.tgz", + "integrity": "sha512-PaeokKjAGraNN+s5SIApgsktnJprIyt3zgEIu7awnEdfn29QiB2crTcCzyi2XGpX9rUnTc0cKU07Wm0N0g7H2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.0", + "@swc/core": "^1.15.11" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4 || ^5 || ^6 || ^7 || ^8" + } + }, + "node_modules/@vitest/mocker": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.1.tgz", + "integrity": "sha512-6K1DoBNAPGvuOcSsGA4D6x+5zEEff/KmOOP3uetT2TrGpVfI+HRHRnJJfKi5ib/g1vx8IYHQD8s0pbJz8WQI7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.1", + "estree-walker": "^3.0.3", + "magic-string": "^1.2.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.1.tgz", + "integrity": "sha512-rbto/mF/SGERxEgYOek7Xm6B9b+y+mVoo+f4b2LymYO8zM1b7uB5nHuhVMTP2hxdzgxvGiZYGxGIaMvL5y180Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "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-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/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "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/autoprefixer": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", + "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "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/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.21", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.21.tgz", + "integrity": "sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.9", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz", + "integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==", + "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.11.20", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.420", + "node-releases": "^2.0.54", + "update-browserslist-db": "^1.3.2" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/camera-controls": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/camera-controls/-/camera-controls-2.10.1.tgz", + "integrity": "sha512-KnaKdcvkBJ1Irbrzl8XD6WtZltkRjp869Jx8c0ujs9K+9WD+1D7ryBsCiVqJYUqt6i/HR5FxT7RLASieUD+Q5w==", + "license": "MIT", + "peerDependencies": { + "three": ">=0.126.1" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cmdk": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz", + "integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-id": "^1.1.0", + "@radix-ui/react-primitive": "^2.0.2" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" + } + }, + "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/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cssstyle": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.2.0.tgz", + "integrity": "sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.0.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.28", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.6" + }, + "engines": { + "node": ">=20" + } + }, + "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==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/date-fns": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", + "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "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/detect-gpu": { + "version": "5.0.70", + "resolved": "https://registry.npmjs.org/detect-gpu/-/detect-gpu-5.0.70.tgz", + "integrity": "sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w==", + "license": "MIT", + "dependencies": { + "webgl-constants": "^1.1.1" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "license": "MIT" + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/draco3d": { + "version": "1.5.7", + "resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz", + "integrity": "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==", + "license": "Apache-2.0" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.422", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.422.tgz", + "integrity": "sha512-UvA/32XqrLDdZSn7Jllo1AYNcWji/G0d5M0GTViE7KoGBiMunw3a34Sb2KO4ZZyrSEhqsxFoVhWWJshdyfKqJA==", + "dev": true, + "license": "ISC" + }, + "node_modules/embla-carousel": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", + "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", + "license": "MIT" + }, + "node_modules/embla-carousel-react": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel-react/-/embla-carousel-react-8.6.0.tgz", + "integrity": "sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==", + "license": "MIT", + "dependencies": { + "embla-carousel": "8.6.0", + "embla-carousel-reactive-utils": "8.6.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/embla-carousel-reactive-utils": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.6.0.tgz", + "integrity": "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A==", + "license": "MIT", + "peerDependencies": { + "embla-carousel": "8.6.0" + } + }, + "node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/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/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@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", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.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", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.26", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", + "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "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", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "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", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "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/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-equals": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", + "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "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/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, + "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/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "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/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "15.15.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", + "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glsl-noise": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/glsl-noise/-/glsl-noise-0.0.0.tgz", + "integrity": "sha512-b/ZCF6amfAUb7dJM/MxRs7AetQEahYzJ8PtgfrmEdtw6uyGOr+ZSGtgjFm6mfsBkxJ4d2W7kg+Nlqzqvn3Bc0w==", + "license": "MIT" + }, + "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", + "engines": { + "node": ">=8" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hls.js": { + "version": "1.6.16", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.16.tgz", + "integrity": "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==", + "license": "Apache-2.0" + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "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/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "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/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/input-otp": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/input-otp/-/input-otp-1.4.2.tgz", + "integrity": "sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-promise": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", + "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==", + "license": "MIT" + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/its-fine": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-1.2.5.tgz", + "integrity": "sha512-fXtDA0X0t0eBYAGLVM5YsgJGsJ5jEmqZEPrGbzdf5awjv0xE7nqv3TVnvtUF060Tkes15DbDAKW/I48vsb6SyA==", + "license": "MIT", + "dependencies": { + "@types/react-reconciler": "^0.28.0" + }, + "peerDependencies": { + "react": ">=18.0" + } + }, + "node_modules/its-fine/node_modules/@types/react-reconciler": { + "version": "0.28.9", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz", + "integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "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/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "28.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz", + "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.31", + "@asamuzakjp/dom-selector": "^6.8.1", + "@bramus/specificity": "^2.4.2", + "@exodus/bytes": "^1.11.0", + "cssstyle": "^6.0.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "undici": "^7.21.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "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/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "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/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, + "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/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "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/lovable-tagger": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/lovable-tagger/-/lovable-tagger-1.3.0.tgz", + "integrity": "sha512-X2TX4g+/8VUFIBboGBjgXIdjEhR1ojtz9W65MO3g069DXTX5PPFZ4ihQzzociTL9HCwCC8SleVGT/wcfURhMXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "tailwindcss": "^3.4.17" + }, + "peerDependencies": { + "vite": ">=5.0.0 <9.0.0" + } + }, + "node_modules/lovable-tagger/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/lovable-tagger/node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/lovable-tagger/node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/lovable-tagger/node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/lovable-tagger/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/lovable-tagger/node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/lovable-tagger/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/lovable-tagger/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/lovable-tagger/node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/lovable-tagger/node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/lovable-tagger/node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/lovable-tagger/node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/lovable-tagger/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/lovable-tagger/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/lovable-tagger/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/lovable-tagger/node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/lovable-tagger/node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/lovable-tagger/node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/lovable-tagger/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/lovable-tagger/node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/lovable-tagger/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/lovable-tagger/node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/lovable-tagger/node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/lovable-tagger/node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/lovable-tagger/node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/lovable-tagger/node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/lovable-tagger/node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/lucide-react": { + "version": "0.462.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.462.0.tgz", + "integrity": "sha512-NTL7EbAao9IFtuSivSZgrAh4fZd09Lr+6MTkqIxuHaH2nnYiYIzXPo06cOxHg9wKLdj6LL8TByG4qpePqwgx/g==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + } + }, + "node_modules/maath": { + "version": "0.10.8", + "resolved": "https://registry.npmjs.org/maath/-/maath-0.10.8.tgz", + "integrity": "sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g==", + "license": "MIT", + "peerDependencies": { + "@types/three": ">=0.134.0", + "three": ">=0.134.0" + } + }, + "node_modules/magic-string": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz", + "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/meshline": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/meshline/-/meshline-3.3.1.tgz", + "integrity": "sha512-/TQj+JdZkeSUOl5Mk2J7eLcYTLiQm2IDzmlSvYm7ov15anEcDJ92GHqqazxTSreeNgfnYu24kiEvvv0WlbCdFQ==", + "license": "MIT", + "peerDependencies": { + "three": ">=0.137" + } + }, + "node_modules/meshoptimizer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz", + "integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==", + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "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/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "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/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/next-themes": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.3.0.tgz", + "integrity": "sha512-/QHIrsYpd6Kfk7xakK4svpDI5mmXP0gfvCoJdGpZQ2TOrQZmsW0QxjaiLn8wbIKjtm4BTSqLoix4lxYYOnLJ/w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8 || ^17 || ^18", + "react-dom": "^16.8 || ^17 || ^18" + } + }, + "node_modules/node-releases": { + "version": "2.0.54", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", + "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "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/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "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.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-nested/node_modules/postcss-selector-parser": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", + "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "license": "MIT" + }, + "node_modules/potpack": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz", + "integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==", + "license": "ISC" + }, + "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/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/promise-worker-transferable": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/promise-worker-transferable/-/promise-worker-transferable-1.0.4.tgz", + "integrity": "sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw==", + "license": "Apache-2.0", + "dependencies": { + "is-promise": "^2.1.0", + "lie": "^3.0.2" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.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/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "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-composer": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/react-composer/-/react-composer-5.0.3.tgz", + "integrity": "sha512-1uWd07EME6XZvMfapwZmc7NgCZqDemcvicRi3wMJzXsQLvZ3L7fTHVyPy1bZdnWXM4iPjYuNE+uJ41MLKeTtnA==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.6.0" + }, + "peerDependencies": { + "react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/react-day-picker": { + "version": "8.10.2", + "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.10.2.tgz", + "integrity": "sha512-LK68OTbHB3oJNhl9cA0qVizzp3o26w61YSjAFkYi67N86iro32wx86kSNeFU/hq+gI8m1yzWhnomMLfZ041RzQ==", + "license": "MIT", + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/gpbl" + }, + "peerDependencies": { + "date-fns": "^2.28.0 || ^3.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.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-dom/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/react-hook-form": { + "version": "7.77.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.77.0.tgz", + "integrity": "sha512-Sslh9YDYc0GDlWT/lxasnIduNo4v3yyvqRGvmGKUre5AFjDs/HV9/OafHGD8d+sB2yoL4UIL9L8X9i0WlZZebg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react-reconciler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.27.0.tgz", + "integrity": "sha512-HmMDKciQjYmBRGuuhIaKA1ba/7a+UsM5FzOZsMO2JYHt9Jh8reCb7j1eDC95NOyUlKM9KRyvdx0flBuDvYSBoA==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.21.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^18.0.0" + } + }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-resizable-panels": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-2.1.9.tgz", + "integrity": "sha512-z77+X08YDIrgAes4jl8xhnUu1LNIRp4+E7cv4xHmLOxxUPO/ML7PSrE813b90vj7xvQ1lcf7g2uA9GeMZonjhQ==", + "license": "MIT", + "peerDependencies": { + "react": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/react-router": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.3.tgz", + "integrity": "sha512-gyXgtdr5uACJ5b1Q4udzjVV+tb/rlHIMJKuJ0e89R4Kzgz47z/rgP0dIKxktqIEUhDHluGTPJJH/wRha7CyqsA==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.3.tgz", + "integrity": "sha512-ytVbyBBM7vMfRCam25r0WMhSVSom909A8p+8m0/f1w853dz/xfFu6etAT2SEbVoSnI+ZoPRDqIsQXVT89gp7kg==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.3" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/react-use-measure": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz", + "integrity": "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.13", + "react-dom": ">=16.13" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/recharts": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", + "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "deprecated": "1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "license": "MIT", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/recharts/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rolldown": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.133.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.21.0.tgz", + "integrity": "sha512-1r87x5fz9MXqswA2ERLo0EbOAU74DpIUO090gIasYTqlVoJeMcl+Z1Rg7WHz+qtPujhS/hGIt9kxZOYBV3faRQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.2.tgz", + "integrity": "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, + "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==", + "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==", + "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/sonner": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-1.7.4.tgz", + "integrity": "sha512-DIS8z4PfJRbIyfVFDVnK9rO3eYDtse4Omcm6bt0oEr5/jtLgysmjuBl1frJ9E/EQZrFmKx2A8m/s5s9CRXIzhw==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "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==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/stats-gl": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/stats-gl/-/stats-gl-2.4.2.tgz", + "integrity": "sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ==", + "license": "MIT", + "dependencies": { + "@types/three": "*", + "three": "^0.170.0" + }, + "peerDependencies": { + "@types/three": "*", + "three": "*" + } + }, + "node_modules/stats-gl/node_modules/three": { + "version": "0.170.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.170.0.tgz", + "integrity": "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==", + "license": "MIT" + }, + "node_modules/stats.js": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/stats.js/-/stats.js-0.17.0.tgz", + "integrity": "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw==", + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "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/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/suspend-react": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/suspend-react/-/suspend-react-0.1.3.tgz", + "integrity": "sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==", + "license": "MIT", + "peerDependencies": { + "react": ">=17.0" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tailwind-merge": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz", + "integrity": "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tailwindcss-animate": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz", + "integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==", + "license": "MIT", + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders" + } + }, + "node_modules/tailwindcss/node_modules/postcss-selector-parser": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/three": { + "version": "0.177.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.177.0.tgz", + "integrity": "sha512-EiXv5/qWAaGI+Vz2A+JfavwYCMdGjxVsrn3oBwllUoqYeaBO75J63ZfyaQKoiLrqNHoTlUc6PFgMXnS0kI45zg==", + "license": "MIT" + }, + "node_modules/three-mesh-bvh": { + "version": "0.7.8", + "resolved": "https://registry.npmjs.org/three-mesh-bvh/-/three-mesh-bvh-0.7.8.tgz", + "integrity": "sha512-BGEZTOIC14U0XIRw3tO4jY7IjP7n7v24nv9JXS1CyeVRWOCkcOMhRnmENUjuV39gktAw4Ofhr0OvIAiTspQrrw==", + "deprecated": "Deprecated due to three.js version incompatibility. Please use v0.8.0, instead.", + "license": "MIT", + "peerDependencies": { + "three": ">= 0.151.0" + } + }, + "node_modules/three-stdlib": { + "version": "2.36.1", + "resolved": "https://registry.npmjs.org/three-stdlib/-/three-stdlib-2.36.1.tgz", + "integrity": "sha512-XyGQrFmNQ5O/IoKm556ftwKsBg11TIb301MB5dWNicziQBEs2g3gtOYIf7pFiLa0zI2gUwhtCjv9fmjnxKZ1Cg==", + "license": "MIT", + "dependencies": { + "@types/draco3d": "^1.4.0", + "@types/offscreencanvas": "^2019.6.4", + "@types/webxr": "^0.5.2", + "draco3d": "^1.4.1", + "fflate": "^0.6.9", + "potpack": "^1.0.1" + }, + "peerDependencies": { + "three": ">=0.128.0" + } + }, + "node_modules/three-stdlib/node_modules/fflate": { + "version": "0.6.11", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.6.11.tgz", + "integrity": "sha512-3JyEFWGjFn7zHmoa9+zG1BmW7X2okcmAB+0Cnu9UFbVs/jCBnl2A8o065ZlXiw145K3eBM3uLuzrYXC0RK7eDg==", + "license": "MIT" + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz", + "integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "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/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tldts": { + "version": "7.4.12", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.12.tgz", + "integrity": "sha512-WylhSDKVeYnWXL3a+vKTaOxjnOeEGw938hImY8zoRWJjRRK/Jp1K+IihBzIONpUmW4e3WmXT6q5FW6vlESVZCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.12" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.12", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.12.tgz", + "integrity": "sha512-nYNzS2WRf4QJmjzFFgAxLOBjyBxAGRbCy9PVBPaglcYyYajh40VBn+v5Ngr96ZMc7oM0+aCJdtQnNejvdBnXMQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/troika-three-text": { + "version": "0.52.4", + "resolved": "https://registry.npmjs.org/troika-three-text/-/troika-three-text-0.52.4.tgz", + "integrity": "sha512-V50EwcYGruV5rUZ9F4aNsrytGdKcXKALjEtQXIOBfhVoZU9VAqZNIoGQ3TMiooVqFAbR1w15T+f+8gkzoFzawg==", + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.2", + "troika-three-utils": "^0.52.4", + "troika-worker-utils": "^0.52.0", + "webgl-sdf-generator": "1.1.1" + }, + "peerDependencies": { + "three": ">=0.125.0" + } + }, + "node_modules/troika-three-utils": { + "version": "0.52.4", + "resolved": "https://registry.npmjs.org/troika-three-utils/-/troika-three-utils-0.52.4.tgz", + "integrity": "sha512-NORAStSVa/BDiG52Mfudk4j1FG4jC4ILutB3foPnfGbOeIs9+G5vZLa0pnmnaftZUGm4UwSoqEpWdqvC7zms3A==", + "license": "MIT", + "peerDependencies": { + "three": ">=0.125.0" + } + }, + "node_modules/troika-worker-utils": { + "version": "0.52.0", + "resolved": "https://registry.npmjs.org/troika-worker-utils/-/troika-worker-utils-0.52.0.tgz", + "integrity": "sha512-W1CpvTHykaPH5brv5VHLfQo9D1OYuo0cSBEUQFFT/nBUzM8iD6Lq2/tgG/f1OelbAS1WtaTPQzE5uM49egnngw==", + "license": "MIT" + }, + "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/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "license": "Apache-2.0" + }, + "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==", + "license": "0BSD" + }, + "node_modules/tunnel-rat": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/tunnel-rat/-/tunnel-rat-0.1.2.tgz", + "integrity": "sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==", + "license": "MIT", + "dependencies": { + "zustand": "^4.3.2" + } + }, + "node_modules/tunnel-rat/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "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/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/typescript-eslint": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.60.1.tgz", + "integrity": "sha512-6m5hkkRAp8lKvhVpcprAIn5KkehQEh+47oHH2VGnExEh7dhNxXlg6GPAOIu6TxbVQxhebrJDvjl3020ooiWCMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.60.1", + "@typescript-eslint/parser": "8.60.1", + "@typescript-eslint/typescript-estree": "8.60.1", + "@typescript-eslint/utils": "8.60.1" + }, + "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": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.1.tgz", + "integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", + "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/urdf-loader": { + "version": "0.12.7", + "resolved": "https://registry.npmjs.org/urdf-loader/-/urdf-loader-0.12.7.tgz", + "integrity": "sha512-9RQDhWt4x9K6R3uHjv68NLwX+JDm3A2XjhksrMgq6xl9NJcwqV21+htHU0cbG619hnqH1d4mUMFuqHo22R8DMA==", + "license": "Apache-2.0", + "peerDependencies": { + "three": ">=0.152.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/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utility-types": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz", + "integrity": "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/vaul": { + "version": "0.9.9", + "resolved": "https://registry.npmjs.org/vaul/-/vaul-0.9.9.tgz", + "integrity": "sha512-7afKg48srluhZwIkaU+lgGtFCUsYBSGOl8vcc8N/M3YQlZFlynHD15AE+pwrYdc826o7nrIND4lL9Y6b9WWZZQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-dialog": "^1.1.1" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", + "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.1.tgz", + "integrity": "sha512-iA95lQbKEkvrtTkdAgnWbXfbipWiiWe/hDl2P5tMi6WFwD76G0NxXAGp/M9EOcYupeGJRr6wppMc7CoA41TQjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.1", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "6.1.4", + "tinyexec": "1.3.0", + "tinyglobby": "^0.2.17", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.1", + "@vitest/browser-preview": "5.0.1", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.1", + "@vitest/coverage-v8": "5.0.1", + "@vitest/ui": "5.0.1", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webgl-constants": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/webgl-constants/-/webgl-constants-1.1.1.tgz", + "integrity": "sha512-LkBXKjU5r9vAW7Gcu3T5u+5cvSvh5WwINdr0C+9jpzVB41cjQAP5ePArDtk/WHYdVj0GefCgM73BA7FlIiNtdg==" + }, + "node_modules/webgl-sdf-generator": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/webgl-sdf-generator/-/webgl-sdf-generator-1.1.1.tgz", + "integrity": "sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA==", + "license": "MIT" + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "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/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/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", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zustand": { + "version": "5.0.14", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz", + "integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000000000000000000000000000000000000..29069686163dd036251d448745587f944b8c6a76 --- /dev/null +++ b/package.json @@ -0,0 +1,91 @@ +{ + "name": "vite_react_shadcn_ts", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "test": "vitest run", + "dev": "vite", + "build": "vite build", + "build:dev": "vite build --mode development", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@hookform/resolvers": "^3.9.0", + "@radix-ui/react-accordion": "^1.2.0", + "@radix-ui/react-alert-dialog": "^1.1.1", + "@radix-ui/react-aspect-ratio": "^1.1.0", + "@radix-ui/react-avatar": "^1.1.0", + "@radix-ui/react-checkbox": "^1.1.1", + "@radix-ui/react-collapsible": "^1.1.0", + "@radix-ui/react-context-menu": "^2.2.1", + "@radix-ui/react-dialog": "^1.1.2", + "@radix-ui/react-dropdown-menu": "^2.1.1", + "@radix-ui/react-hover-card": "^1.1.1", + "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-menubar": "^1.1.1", + "@radix-ui/react-navigation-menu": "^1.2.0", + "@radix-ui/react-popover": "^1.1.1", + "@radix-ui/react-progress": "^1.1.0", + "@radix-ui/react-radio-group": "^1.2.0", + "@radix-ui/react-scroll-area": "^1.1.0", + "@radix-ui/react-select": "^2.1.1", + "@radix-ui/react-separator": "^1.1.0", + "@radix-ui/react-slider": "^1.2.0", + "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-switch": "^1.1.0", + "@radix-ui/react-tabs": "^1.1.0", + "@radix-ui/react-toast": "^1.2.1", + "@radix-ui/react-toggle": "^1.1.0", + "@radix-ui/react-toggle-group": "^1.1.0", + "@radix-ui/react-tooltip": "^1.1.4", + "@react-three/drei": "^9.122.0", + "@react-three/fiber": "^8.18.0", + "@tanstack/react-query": "^5.56.2", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.0.0", + "date-fns": "^3.6.0", + "embla-carousel-react": "^8.3.0", + "input-otp": "^1.2.4", + "jszip": "^3.10.1", + "lucide-react": "^0.462.0", + "next-themes": "^0.3.0", + "react": "^18.3.1", + "react-day-picker": "^8.10.1", + "react-dom": "^18.3.1", + "react-hook-form": "^7.53.0", + "react-resizable-panels": "^2.1.3", + "react-router-dom": "^7.18.3", + "recharts": "^2.12.7", + "sonner": "^1.5.0", + "tailwind-merge": "^2.5.2", + "tailwindcss-animate": "^1.0.7", + "three": "^0.177.0", + "urdf-loader": "^0.12.6", + "vaul": "^0.9.3", + "zod": "^3.23.8" + }, + "devDependencies": { + "@eslint/js": "^9.9.0", + "@tailwindcss/typography": "^0.5.15", + "@types/node": "^22.5.5", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react-swc": "^4.3.1", + "autoprefixer": "^10.4.20", + "eslint": "^9.9.0", + "eslint-plugin-react-hooks": "^5.1.0-rc.0", + "eslint-plugin-react-refresh": "^0.4.9", + "globals": "^15.9.0", + "jsdom": "^28.1.0", + "lovable-tagger": "^1.3.0", + "postcss": "^8.5.25", + "tailwindcss": "^3.4.11", + "typescript": "^5.5.3", + "typescript-eslint": "^8.0.1", + "vite": "^8.0.16", + "vitest": "^5.0.1" + } +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000000000000000000000000000000000000..2e7af2b7f1a6f391da1631d93968a9d487ba977d --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..424101de717bf411d1db8456030c97d184d4c005 Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/lovable-uploads/5e648747-34b7-4d8f-93fd-4dbd00aeeefc.png b/public/lovable-uploads/5e648747-34b7-4d8f-93fd-4dbd00aeeefc.png new file mode 100644 index 0000000000000000000000000000000000000000..39595804e566c6c8298b4dfbb31bf7f314c2e562 Binary files /dev/null and b/public/lovable-uploads/5e648747-34b7-4d8f-93fd-4dbd00aeeefc.png differ diff --git a/public/placeholder.svg b/public/placeholder.svg new file mode 100644 index 0000000000000000000000000000000000000000..9b13eb69f97b905ae787cf4493758dd6e7bc2e72 --- /dev/null +++ b/public/placeholder.svg @@ -0,0 +1 @@ + diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000000000000000000000000000000000000..6018e701fc7dd0317cda9eceea390524322e8a05 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,14 @@ +User-agent: Googlebot +Allow: / + +User-agent: Bingbot +Allow: / + +User-agent: Twitterbot +Allow: / + +User-agent: facebookexternalhit +Allow: / + +User-agent: * +Allow: / diff --git a/public/so-101-urdf/CMakeLists.txt b/public/so-101-urdf/CMakeLists.txt new file mode 100755 index 0000000000000000000000000000000000000000..775381afc316a8145c5ac319e3ffc610262779de --- /dev/null +++ b/public/so-101-urdf/CMakeLists.txt @@ -0,0 +1,37 @@ +cmake_minimum_required(VERSION 3.10.2) + +project(so_arm_description) + +find_package(ament_cmake REQUIRED) +find_package(urdf REQUIRED) + +# Install the mesh files from SO101/assets +install( + DIRECTORY + SO101/assets/ + DESTINATION + share/${PROJECT_NAME}/meshes + FILES_MATCHING PATTERN "*.stl" +) + +# Install URDF files +install( + DIRECTORY + urdf/ + DESTINATION + share/${PROJECT_NAME}/urdf + FILES_MATCHING PATTERN "*.urdf" +) + +# Install other directories +install( + DIRECTORY + meshes + config + launch + DESTINATION + share/${PROJECT_NAME} + OPTIONAL +) + +ament_package() diff --git a/public/so-101-urdf/README.md b/public/so-101-urdf/README.md new file mode 100644 index 0000000000000000000000000000000000000000..b17a93230eaeec5830e207008af3a4f8517b218e --- /dev/null +++ b/public/so-101-urdf/README.md @@ -0,0 +1,45 @@ +# SO-ARM ROS2 URDF Package + +A complete ROS2 package for the SO-ARM101 robotic arm with URDF description. + +## 📋 Overview + +This package provides a complete ROS2 implementation for the SO-ARM101 robotic arm, including: + +- URDF robot description with visual and collision meshes +- RViz visualization with pre-configured displays +- Launch files for easy robot visualization +- Integration with MoveIt for motion planning +- Joint state publishers for interactive control + +## 🎯 Original Source + +https://github.com/TheRobotStudio/SO-ARM100/tree/main/Simulation/SO101 + +## 🚀 Key Improvements Made + +### 1. **Complete ROS2 Package Structure** + +- ✅ Proper `package.xml` with all necessary dependencies +- ✅ CMakeLists.txt for ROS2 build system +- ✅ Organized directory structure following ROS2 conventions + +### 2. **Enhanced Visualization** + +- ✅ Fixed mesh file paths for proper package integration + +### Build Instructions + +1. Clone this repository into your ROS2 workspace: + + ```bash + cd ~/your_ros2_ws/src + git clone so_arm_description + ``` + +2. Build the package: + ```bash + cd ~/your_ros2_ws + colcon build --packages-select so_arm_description + source install/setup.bash + ``` diff --git a/public/so-101-urdf/config/joint_names_so_arm_urdf.yaml b/public/so-101-urdf/config/joint_names_so_arm_urdf.yaml new file mode 100644 index 0000000000000000000000000000000000000000..1f0c831755649001a8ba1a88e81e162b944a710c --- /dev/null +++ b/public/so-101-urdf/config/joint_names_so_arm_urdf.yaml @@ -0,0 +1 @@ +controller_joint_names: ['', 'Rotation', 'Pitch', 'Elbow', 'Wrist_Pitch', 'Wrist_Roll', 'Jaw', ] diff --git a/public/so-101-urdf/joints_properties.xml b/public/so-101-urdf/joints_properties.xml new file mode 100644 index 0000000000000000000000000000000000000000..684ffd45766219a22be6e7dc23bcfd3c2f8c9191 --- /dev/null +++ b/public/so-101-urdf/joints_properties.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + diff --git a/public/so-101-urdf/meshes/base_motor_holder_so101_v1.stl b/public/so-101-urdf/meshes/base_motor_holder_so101_v1.stl new file mode 100644 index 0000000000000000000000000000000000000000..ac9c38076fe1036517faf0bccadea5de9dce0097 --- /dev/null +++ b/public/so-101-urdf/meshes/base_motor_holder_so101_v1.stl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8cd2f241037ea377af1191fffe0dd9d9006beea6dcc48543660ed41647072424 +size 1877084 diff --git a/public/so-101-urdf/meshes/base_so101_v2.stl b/public/so-101-urdf/meshes/base_so101_v2.stl new file mode 100644 index 0000000000000000000000000000000000000000..503d30be06a91e401ba8d46ebb7e650866229550 --- /dev/null +++ b/public/so-101-urdf/meshes/base_so101_v2.stl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bb12b7026575e1f70ccc7240051f9d943553bf34e5128537de6cd86fae33924d +size 471584 diff --git a/public/so-101-urdf/meshes/motor_holder_so101_base_v1.stl b/public/so-101-urdf/meshes/motor_holder_so101_base_v1.stl new file mode 100644 index 0000000000000000000000000000000000000000..f8e3d75c027f28bb672f830ec6e0795567c1b7c9 --- /dev/null +++ b/public/so-101-urdf/meshes/motor_holder_so101_base_v1.stl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:31242ae6fb59d8b15c66617b88ad8e9bded62d57c35d11c0c43a70d2f4caa95b +size 1129384 diff --git a/public/so-101-urdf/meshes/motor_holder_so101_wrist_v1.stl b/public/so-101-urdf/meshes/motor_holder_so101_wrist_v1.stl new file mode 100644 index 0000000000000000000000000000000000000000..e55b7194683c6ac301504c6f59137362f0ebd13e --- /dev/null +++ b/public/so-101-urdf/meshes/motor_holder_so101_wrist_v1.stl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:887f92e6013cb64ea3a1ab8675e92da1e0beacfd5e001f972523540545e08011 +size 1052184 diff --git a/public/so-101-urdf/meshes/moving_jaw_so101_v1.stl b/public/so-101-urdf/meshes/moving_jaw_so101_v1.stl new file mode 100644 index 0000000000000000000000000000000000000000..eb17d253df8a84a88472ecc7f859d3b8b4d78884 --- /dev/null +++ b/public/so-101-urdf/meshes/moving_jaw_so101_v1.stl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:785a9dded2f474bc1d869e0d3dae398a3dcd9c0c345640040472210d2861fa9d +size 1413584 diff --git a/public/so-101-urdf/meshes/rotation_pitch_so101_v1.stl b/public/so-101-urdf/meshes/rotation_pitch_so101_v1.stl new file mode 100644 index 0000000000000000000000000000000000000000..b536cb4100c1f204f8a9d9b182acdc4a3afbc66c --- /dev/null +++ b/public/so-101-urdf/meshes/rotation_pitch_so101_v1.stl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9be900cc2a2bf718102841ef82ef8d2873842427648092c8ed2ca1e2ef4ffa34 +size 883684 diff --git a/public/so-101-urdf/meshes/sts3215_03a_no_horn_v1.stl b/public/so-101-urdf/meshes/sts3215_03a_no_horn_v1.stl new file mode 100644 index 0000000000000000000000000000000000000000..18e9335673f6d46ea8fd0a03a791516203eb6f4c --- /dev/null +++ b/public/so-101-urdf/meshes/sts3215_03a_no_horn_v1.stl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:75ef3781b752e4065891aea855e34dc161a38a549549cd0970cedd07eae6f887 +size 865884 diff --git a/public/so-101-urdf/meshes/sts3215_03a_v1.stl b/public/so-101-urdf/meshes/sts3215_03a_v1.stl new file mode 100644 index 0000000000000000000000000000000000000000..a14c57b9033b82f1daa38f45e7e3c91343702df4 --- /dev/null +++ b/public/so-101-urdf/meshes/sts3215_03a_v1.stl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a37c871fb502483ab96c256baf457d36f2e97afc9205313d9c5ab275ef941cd0 +size 954084 diff --git a/public/so-101-urdf/meshes/under_arm_so101_v1.stl b/public/so-101-urdf/meshes/under_arm_so101_v1.stl new file mode 100644 index 0000000000000000000000000000000000000000..47b611ef939e452f791ae749756f717317922cfd --- /dev/null +++ b/public/so-101-urdf/meshes/under_arm_so101_v1.stl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d01d1f2de365651dcad9d6669e94ff87ff7652b5bb2d10752a66a456a86dbc71 +size 1975884 diff --git a/public/so-101-urdf/meshes/upper_arm_so101_v1.stl b/public/so-101-urdf/meshes/upper_arm_so101_v1.stl new file mode 100644 index 0000000000000000000000000000000000000000..8832740f9540065e6006907a9a826b01f96cd122 --- /dev/null +++ b/public/so-101-urdf/meshes/upper_arm_so101_v1.stl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:475056e03a17e71919b82fd88ab9a0b898ab50164f2a7943652a6b2941bb2d4f +size 1303484 diff --git a/public/so-101-urdf/meshes/waveshare_mounting_plate_so101_v2.stl b/public/so-101-urdf/meshes/waveshare_mounting_plate_so101_v2.stl new file mode 100644 index 0000000000000000000000000000000000000000..e0d90d5b6554ab8ca91928fa6521a675089beb12 --- /dev/null +++ b/public/so-101-urdf/meshes/waveshare_mounting_plate_so101_v2.stl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e197e24005a07d01bbc06a8c42311664eaeda415bf859f68fa247884d0f1a6e9 +size 62784 diff --git a/public/so-101-urdf/meshes/wrist_roll_follower_so101_v1.stl b/public/so-101-urdf/meshes/wrist_roll_follower_so101_v1.stl new file mode 100644 index 0000000000000000000000000000000000000000..9a5fa8fe2d7d8e59cd4a30d4dba0ca337513ab4a --- /dev/null +++ b/public/so-101-urdf/meshes/wrist_roll_follower_so101_v1.stl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4b17b410a12d64ec39554abc3e8054d8a97384b2dc4a8d95a5ecb2a93670f5f4 +size 1439884 diff --git a/public/so-101-urdf/meshes/wrist_roll_pitch_so101_v2.stl b/public/so-101-urdf/meshes/wrist_roll_pitch_so101_v2.stl new file mode 100644 index 0000000000000000000000000000000000000000..2f531712f88ec01d09824ee8e27c791a4616516f --- /dev/null +++ b/public/so-101-urdf/meshes/wrist_roll_pitch_so101_v2.stl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6c7ec5525b4d8b9e397a30ab4bb0037156a5d5f38a4adf2c7d943d6c56eda5ae +size 2699784 diff --git a/public/so-101-urdf/package.xml b/public/so-101-urdf/package.xml new file mode 100644 index 0000000000000000000000000000000000000000..91eef666560a8641edec92221b2142a20c227bde --- /dev/null +++ b/public/so-101-urdf/package.xml @@ -0,0 +1,26 @@ + + + + so_arm_description + 1.0.0 + SO-ARM101 URDF Resources + + LycheeAI + + LycheeAI + + BSD + + ament_cmake + + urdf + robot_state_publisher + joint_state_publisher + joint_state_publisher_gui + rviz2 + xacro + + + ament_cmake + + diff --git a/public/so-101-urdf/urdf/so101_new_calib.urdf b/public/so-101-urdf/urdf/so101_new_calib.urdf new file mode 100644 index 0000000000000000000000000000000000000000..8967a31f41574f137d02d4063fd18537fbb6970d --- /dev/null +++ b/public/so-101-urdf/urdf/so101_new_calib.urdf @@ -0,0 +1,435 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + transmission_interface/SimpleTransmission + + hardware_interface/PositionJointInterface + + + hardware_interface/PositionJointInterface + 1 + + + + + + + + + + + + + + transmission_interface/SimpleTransmission + + hardware_interface/PositionJointInterface + + + hardware_interface/PositionJointInterface + 1 + + + + + + + + + + + + + + transmission_interface/SimpleTransmission + + hardware_interface/PositionJointInterface + + + hardware_interface/PositionJointInterface + 1 + + + + + + + + + + + + + + transmission_interface/SimpleTransmission + + hardware_interface/PositionJointInterface + + + hardware_interface/PositionJointInterface + 1 + + + + + + + + + + + + + + transmission_interface/SimpleTransmission + + hardware_interface/PositionJointInterface + + + hardware_interface/PositionJointInterface + 1 + + + + + + + + + + + + + + transmission_interface/SimpleTransmission + + hardware_interface/PositionJointInterface + + + hardware_interface/PositionJointInterface + 1 + + + + diff --git a/src/.gitignore b/src/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..c2658d7d1b31848c3b71960543cb0368e56cd4c7 --- /dev/null +++ b/src/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f53f6fbd886844ae2dbbd1906cadea82823e1adb --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,68 @@ +import { BrowserRouter, Routes, Route } from "react-router-dom"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ThemeProvider } from "@/contexts/ThemeContext"; +import { UrdfProvider } from "@/contexts/UrdfContext"; +import { DragAndDropProvider } from "@/contexts/DragAndDropContext"; +import { Toaster } from "@/components/ui/toaster"; +import Landing from "@/pages/Landing"; +import Teleoperation from "@/pages/Teleoperation"; +import Calibration from "@/pages/Calibration"; +import Recording from "@/pages/Recording"; +import Training from "@/pages/Training"; +import Inference from "@/pages/Inference"; +import EditDataset from "@/pages/EditDataset"; +import Upload from "@/pages/Upload"; + +import NotFound from "@/pages/NotFound"; +import SingleTabGuard from "@/components/SingleTabGuard"; +import TeleopStopNotice from "@/components/TeleopStopNotice"; +import UpdateNotice from "@/components/UpdateNotice"; +import { TooltipProvider } from "@radix-ui/react-tooltip"; +import { ApiProvider } from "./contexts/ApiContext"; +import { HfAuthProvider } from "./contexts/HfAuthContext"; +import { OnboardingProvider } from "./contexts/OnboardingContext"; + +const queryClient = new QueryClient(); + +function App() { + return ( + + + + + + + + + + + + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + } /> + + + + + + + + + + + + + ); +} + +export default App; diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7b8d89ab36307b0b67b3c6af8fb637655b967a12 --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,68 @@ +import React from "react"; +import { Github, BookOpen } from "lucide-react"; + +const DiscordIcon: React.FC<{ className?: string }> = ({ className }) => ( + + + +); + +const links = [ + { + href: "https://github.com/huggingface/lerobot", + label: "GitHub", + Icon: Github, + }, + { + href: "https://huggingface.co/docs/lerobot", + label: "Documentation", + Icon: BookOpen, + }, + { + href: "https://discord.com/invite/s3KuuzsPFb", + label: "Discord", + Icon: DiscordIcon, + }, +]; + +const Footer: React.FC = () => { + return ( + + ); +}; + +export default Footer; diff --git a/src/components/Logo.tsx b/src/components/Logo.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e45b6b7209ef5483b501fca534c6c8e63edf41d2 --- /dev/null +++ b/src/components/Logo.tsx @@ -0,0 +1,19 @@ + +import React from 'react'; +import { cn } from '@/lib/utils'; + +interface LogoProps extends React.HTMLAttributes { + iconOnly?: boolean; +} + +const Logo: React.FC = ({ + className, + iconOnly = false +}) => { + return
+ LeLab Logo + {!iconOnly && LeLab} +
; +}; + +export default Logo; diff --git a/src/components/SingleTabGuard.tsx b/src/components/SingleTabGuard.tsx new file mode 100644 index 0000000000000000000000000000000000000000..02b7fb1595199194ed3296b96d291d47febde1b9 --- /dev/null +++ b/src/components/SingleTabGuard.tsx @@ -0,0 +1,141 @@ +import { useCallback, useEffect, useRef, useState, ReactNode } from "react"; +import { Button } from "@/components/ui/button"; + +type Peer = { id: string; openedAt: number; lastSeen: number }; + +const CHANNEL = "lelab-tabs-v1"; +const HEARTBEAT_MS = 1000; +const PEER_TIMEOUT_MS = 3000; + +const SingleTabGuard = ({ children }: { children: ReactNode }) => { + const [isPrimary, setIsPrimary] = useState(true); + const peersRef = useRef>(new Map()); + const myIdRef = useRef(""); + const myOpenedAtRef = useRef(0); + const channelRef = useRef(null); + + const recompute = useCallback(() => { + const peers = peersRef.current; + const cutoff = Date.now() - PEER_TIMEOUT_MS; + for (const [id, peer] of peers) { + if (peer.lastSeen < cutoff) peers.delete(id); + } + let winnerId = myIdRef.current; + let winnerOpenedAt = myOpenedAtRef.current; + for (const peer of peers.values()) { + if ( + peer.openedAt < winnerOpenedAt || + (peer.openedAt === winnerOpenedAt && peer.id < winnerId) + ) { + winnerId = peer.id; + winnerOpenedAt = peer.openedAt; + } + } + setIsPrimary(winnerId === myIdRef.current); + }, []); + + useEffect(() => { + if (typeof window === "undefined" || typeof BroadcastChannel === "undefined") { + return; + } + + myIdRef.current = crypto.randomUUID(); + myOpenedAtRef.current = Date.now(); + + const channel = new BroadcastChannel(CHANNEL); + channelRef.current = channel; + + const send = (type: string) => { + channel.postMessage({ + type, + id: myIdRef.current, + openedAt: myOpenedAtRef.current, + }); + }; + + channel.onmessage = (e) => { + const msg = e.data; + if (!msg || msg.id === myIdRef.current) return; + const peers = peersRef.current; + + if (msg.type === "HEARTBEAT") { + peers.set(msg.id, { + id: msg.id, + openedAt: msg.openedAt, + lastSeen: Date.now(), + }); + } else if (msg.type === "RELEASE") { + peers.delete(msg.id); + } else if (msg.type === "TAKEOVER") { + peers.set(msg.id, { + id: msg.id, + openedAt: msg.openedAt, + lastSeen: Date.now(), + }); + // Move ourselves behind the taker so the election flips. + if (myOpenedAtRef.current <= msg.openedAt) { + myOpenedAtRef.current = msg.openedAt + 1; + } + } + recompute(); + }; + + send("HEARTBEAT"); + const interval = setInterval(() => { + send("HEARTBEAT"); + recompute(); + }, HEARTBEAT_MS); + + const onUnload = () => send("RELEASE"); + window.addEventListener("beforeunload", onUnload); + + return () => { + window.removeEventListener("beforeunload", onUnload); + clearInterval(interval); + send("RELEASE"); + channel.close(); + channelRef.current = null; + }; + }, [recompute]); + + const takeOver = useCallback(() => { + myOpenedAtRef.current = 0; + channelRef.current?.postMessage({ + type: "TAKEOVER", + id: myIdRef.current, + openedAt: 0, + }); + recompute(); + }, [recompute]); + + // A non-primary tab renders only the blocker, not `children`. Anything the + // app mounts as a descendant (a Radix modal that sets `pointer-events: none` + // on `body`, the onboarding welcome dialog and `?` launcher) would otherwise + // sit behind this overlay and, in the modal case, kill the "Use this tab" + // button along with the rest of the page. + return ( + <> + {isPrimary && children} + {!isPrimary && ( +
+
+

+ LeLab is already open in another tab +

+

+ Only one tab can control the robot at a time. Switch back to the + original tab, or take over here — the other tab will lock. +

+ +
+
+ )} + + ); +}; + +export default SingleTabGuard; diff --git a/src/components/TeleopStopNotice.tsx b/src/components/TeleopStopNotice.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d8a12bd6c53f10d360f77c02d929c8625f32866e --- /dev/null +++ b/src/components/TeleopStopNotice.tsx @@ -0,0 +1,36 @@ +import { useEffect } from "react"; +import { useToast } from "@/hooks/use-toast"; + +const FLAG = "lelab:teleop-stopped"; + +/** + * One-time confirmation that teleoperation was stopped during the previous + * page's unload (a browser navigation, reload, or tab close from the + * teleoperation page set a sessionStorage flag, since React cleanup can't run + * in those cases). On the next fresh load we surface a toast wherever the user + * landed, then clear the flag. In-app navigation away from teleop toasts + * directly and never sets the flag, so this never double-fires. + */ +const TeleopStopNotice = () => { + const { toast } = useToast(); + + useEffect(() => { + let stopped = false; + try { + stopped = sessionStorage.getItem(FLAG) === "1"; + if (stopped) sessionStorage.removeItem(FLAG); + } catch { + /* sessionStorage unavailable — nothing to show */ + } + if (stopped) { + toast({ + title: "Teleoperation stopped", + description: "The arm was disconnected cleanly when you left the page.", + }); + } + }, [toast]); + + return null; +}; + +export default TeleopStopNotice; diff --git a/src/components/UpdateNotice.tsx b/src/components/UpdateNotice.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a9c026f32b102fb3957d01a2d8696278dc79f136 --- /dev/null +++ b/src/components/UpdateNotice.tsx @@ -0,0 +1,194 @@ +import { useState } from "react"; +import { Loader2, Copy, Sparkles, ChevronRight } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { useApi } from "@/contexts/ApiContext"; +import { useToast } from "@/hooks/use-toast"; +import { useUpdateCheck } from "@/hooks/useUpdateCheck"; + +/** + * App-level popup that notifies the user when a newer LeLab is available on + * GitHub. Offers a copy-able upgrade command, a best-effort "Update now" button + * (runs the pip upgrade on the backend), and a "don't ask again" opt-out. + */ +const UpdateNotice = () => { + const { status, open, dismiss } = useUpdateCheck(); + const { baseUrl, fetchWithHeaders } = useApi(); + const { toast } = useToast(); + const [dontAsk, setDontAsk] = useState(false); + const [updating, setUpdating] = useState(false); + const [output, setOutput] = useState(null); + + if (!status) return null; + + const behind = + typeof status.commits_behind === "number" && status.commits_behind > 0 + ? `${status.commits_behind} commit${status.commits_behind === 1 ? "" : "s"} behind` + : "A new version is available"; + + const copyCommand = async () => { + if (!status.update_command) return; + try { + await navigator.clipboard.writeText(status.update_command); + toast({ + title: "Copied", + description: "Update command copied to clipboard.", + }); + } catch { + toast({ + title: "Copy failed", + description: "Select and copy the command manually.", + variant: "destructive", + }); + } + }; + + const runUpdate = async () => { + setUpdating(true); + setOutput(null); + try { + const r = await fetchWithHeaders(`${baseUrl}/system/update`, { + method: "POST", + }); + const body: { success: boolean; message: string; output: string } = + await r.json(); + if (body.success) { + toast({ title: "Updated", description: body.message }); + dismiss(false); + } else { + setOutput(body.output || body.message); + toast({ + title: "Update failed", + description: body.message, + variant: "destructive", + }); + } + } catch (e) { + toast({ + title: "Update failed", + description: e instanceof Error ? e.message : String(e), + variant: "destructive", + }); + } finally { + setUpdating(false); + } + }; + + return ( + { + if (!o && !updating) dismiss(dontAsk); + }} + > + e.preventDefault()} + > + + + + LeLab update available + + + You're {behind} 😱. +
+ Update to get the latest fixes and features 🤗. + {status.compare_url && ( + <> + {" "} + + See what changed + + . + + )} +
+
+ +
+ + + + Or update manually + + +
+ + {status.update_command} + + +
+
+
+ + {output && ( +
+              {output}
+            
+ )} + +
+ +
+ + {status.can_auto_update && ( + + )} +
+
+
+
+
+ ); +}; + +export default UpdateNotice; diff --git a/src/components/UrdfViewer.tsx b/src/components/UrdfViewer.tsx new file mode 100644 index 0000000000000000000000000000000000000000..30e2b5cf6080c400b6821eeea1e9884db8bcf304 --- /dev/null +++ b/src/components/UrdfViewer.tsx @@ -0,0 +1,310 @@ +import React, { + useEffect, + useRef, + useState, + useMemo, + useCallback, + memo, +} from "react"; +import { cn } from "@/lib/utils"; + +import URDFManipulator from "urdf-loader/src/urdf-manipulator-element.js"; +import { useUrdf } from "@/hooks/useUrdf"; +import { useRealTimeJoints } from "@/hooks/useRealTimeJoints"; +import { + createUrdfViewer, + fitGridToRobot, + setupMeshLoader, + setupJointHighlighting, + setupModelLoading, + URDFViewerElement, +} from "@/lib/urdfViewerHelpers"; + +// Register the URDFManipulator as a custom element if it hasn't been already +if (typeof window !== "undefined" && !customElements.get("urdf-viewer")) { + customElements.define("urdf-viewer", URDFManipulator); +} +import * as THREE from "three"; + +// Extend the interface for the URDF viewer element to include background property +interface UrdfViewerElement extends HTMLElement { + background?: string; + setJointValue?: (jointName: string, value: number) => void; +} + +const UrdfViewer: React.FC = () => { + const containerRef = useRef(null); + const [highlightedJoint, setHighlightedJoint] = useState(null); + const { registerUrdfProcessor, alternativeUrdfModels, isDefaultModel } = + useUrdf(); + + const cleanupAnimationRef = useRef<(() => void) | null>(null); + const viewerRef = useRef(null); + const hasInitializedRef = useRef(false); + + // Real-time joint updates via WebSocket + const { isConnected: isWebSocketConnected } = useRealTimeJoints({ + viewerRef, + enabled: isDefaultModel, // Only enable WebSocket for default model + }); + + // Add state for custom URDF path + const [customUrdfPath, setCustomUrdfPath] = useState(null); + const [urlModifierFunc, setUrlModifierFunc] = useState< + ((url: string) => string) | null + >(null); + + const packageRef = useRef(""); + + // Implement UrdfProcessor interface for drag and drop + const urdfProcessor = useMemo( + () => ({ + loadUrdf: (urdfPath: string) => { + setCustomUrdfPath(urdfPath); + }, + setUrlModifierFunc: (func: (url: string) => string) => { + setUrlModifierFunc(() => func); + }, + getPackage: () => { + return packageRef.current; + }, + }), + [] + ); + + // Register the URDF processor with the global drag and drop context + useEffect(() => { + registerUrdfProcessor(urdfProcessor); + }, [registerUrdfProcessor, urdfProcessor]); + + // Create URL modifier function for default model + const defaultUrlModifier = useCallback((url: string) => { + console.log(`🔗 defaultUrlModifier called with: ${url}`); + + // Handle various package:// URL formats for the default SO-101 model + if (url.startsWith("package://so_arm_description/meshes/")) { + const modifiedUrl = url.replace( + "package://so_arm_description/meshes/", + "/so-101-urdf/meshes/" + ); + console.log(`🔗 Modified URL (package): ${modifiedUrl}`); + return modifiedUrl; + } + + // Handle case where package path might be partially resolved + if (url.includes("so_arm_description/meshes/")) { + const modifiedUrl = url.replace( + /.*so_arm_description\/meshes\//, + "/so-101-urdf/meshes/" + ); + console.log(`🔗 Modified URL (partial): ${modifiedUrl}`); + return modifiedUrl; + } + + // Handle the specific problematic path pattern we're seeing in logs + if (url.includes("/so-101-urdf/so_arm_description/meshes/")) { + const modifiedUrl = url.replace( + "/so-101-urdf/so_arm_description/meshes/", + "/so-101-urdf/meshes/" + ); + console.log(`🔗 Modified URL (problematic path): ${modifiedUrl}`); + return modifiedUrl; + } + + // Handle relative paths that might need mesh folder prefix + if ( + url.endsWith(".stl") && + !url.startsWith("/") && + !url.startsWith("http") + ) { + const modifiedUrl = `/so-101-urdf/meshes/${url}`; + console.log(`🔗 Modified URL (relative): ${modifiedUrl}`); + return modifiedUrl; + } + + console.log(`🔗 Unmodified URL: ${url}`); + return url; + }, []); + + // Main effect to create and setup the viewer only once + useEffect(() => { + if (!containerRef.current) return; + + // Create and configure the URDF viewer element + const viewer = createUrdfViewer(containerRef.current, true); + viewerRef.current = viewer; // Store reference to the viewer + + // Setup mesh loading function with appropriate URL modifier + const activeUrlModifier = isDefaultModel + ? defaultUrlModifier + : urlModifierFunc; + setupMeshLoader(viewer, activeUrlModifier); + + // Determine which URDF to load - fixed path to match the actual available file + const urdfPath = isDefaultModel + ? "/so-101-urdf/urdf/so101_new_calib.urdf" + : customUrdfPath || ""; + + // Set the package path for the default model + if (isDefaultModel) { + packageRef.current = "/"; // Set to root so we can handle full path resolution in URL modifier + } + + // Setup model loading if a path is available + let cleanupModelLoading = () => {}; + if (urdfPath) { + cleanupModelLoading = setupModelLoading( + viewer, + urdfPath, + packageRef.current, + setCustomUrdfPath, + alternativeUrdfModels + ); + } + + // Setup joint highlighting + const cleanupJointHighlighting = setupJointHighlighting( + viewer, + setHighlightedJoint + ); + + // Function to fit the robot to the camera view + const fitRobotToView = (viewer: URDFViewerElement) => { + if (!viewer || !viewer.robot) { + console.log( + "[RobotViewer] Cannot fit to view: No viewer or robot available" + ); + return; + } + + try { + // Create a bounding box for the robot + const boundingBox = new THREE.Box3().setFromObject(viewer.robot); + + // Calculate the center of the bounding box + const center = new THREE.Vector3(); + boundingBox.getCenter(center); + + // Calculate the size of the bounding box + const size = new THREE.Vector3(); + boundingBox.getSize(size); + + // Get the maximum dimension to ensure the entire robot is visible + const maxDim = Math.max(size.x, size.y, size.z); + + // Rescale the ground grid to this robot's actual unit scale — it + // starts as a 1x1 placeholder since it's created before any model + // is loaded (see createUrdfViewer). + fitGridToRobot(viewer, center, boundingBox.min.y, maxDim); + + // Position camera to see the center of the model + viewer.camera.position.copy(center); + + // Move the camera back to see the entire robot + // Use the model's up direction to determine which axis to move along + const upVector = new THREE.Vector3(); + if (viewer.up === "+Z" || viewer.up === "Z") { + upVector.set(1, 1, 1); // Move back in a diagonal + } else if (viewer.up === "+Y" || viewer.up === "Y") { + upVector.set(1, 1, 1); // Move back in a diagonal + } else { + upVector.set(1, 1, 1); // Default direction + } + + // Normalize the vector and multiply by the size + upVector.normalize().multiplyScalar(maxDim * 1.3); + viewer.camera.position.add(upVector); + + // Make the camera look at the center of the model + viewer.controls.target.copy(center); + + // Update controls and mark for redraw + viewer.controls.update(); + viewer.redraw(); + + console.log("[RobotViewer] Robot auto-fitted to view"); + } catch (error) { + console.error("[RobotViewer] Error fitting robot to view:", error); + } + }; + + // Add event listener for when the robot is loaded to auto-fit to view + const onRobotLoad = () => { + fitRobotToView(viewer); + }; + + // Setup animation event handler for the default model or when hasAnimation is true + const onModelProcessed = () => { + hasInitializedRef.current = true; + if ("setJointValue" in viewer) { + // Clear any existing animation + if (cleanupAnimationRef.current) { + cleanupAnimationRef.current(); + cleanupAnimationRef.current = null; + } + } + // Auto-fit the robot to view when the model is processed + onRobotLoad(); + }; + + viewer.addEventListener("urdf-processed", onModelProcessed); + + // Return cleanup function + return () => { + if (cleanupAnimationRef.current) { + cleanupAnimationRef.current(); + cleanupAnimationRef.current = null; + } + hasInitializedRef.current = false; + cleanupJointHighlighting(); + cleanupModelLoading(); + viewer.removeEventListener("urdf-processed", onModelProcessed); + }; + }, [ + isDefaultModel, + customUrdfPath, + urlModifierFunc, + defaultUrlModifier, + alternativeUrdfModels, + ]); + + return ( +
+
+ + {/* Joint highlight indicator */} + {highlightedJoint && ( +
+ Joint: {highlightedJoint} +
+ )} + + {/* WebSocket connection status */} + {isDefaultModel && ( +
+
+
+ {isWebSocketConnected ? "Live Robot Data" : "Disconnected"} +
+
+ )} +
+ ); +}; + +export default memo(UrdfViewer); diff --git a/src/components/control/BrowserCameraPreview.test.tsx b/src/components/control/BrowserCameraPreview.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c6bca6f70948d48ad4759e1eb4bad48682a3df24 --- /dev/null +++ b/src/components/control/BrowserCameraPreview.test.tsx @@ -0,0 +1,51 @@ +// @vitest-environment jsdom +import React, { act } from "react"; +import { createRoot, Root } from "react-dom/client"; +import { afterEach, beforeEach, expect, test, vi } from "vitest"; +import BrowserCameraPreview from "./BrowserCameraPreview"; +import CameraRotationSelect from "./CameraRotationSelect"; +vi.mock("@/hooks/useCameraStream", () => ({ useCameraStream: () => ({ videoRef: React.createRef(), hasError: false }) })); +let root: Root; +let container: HTMLDivElement; +beforeEach(() => { Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); container = document.createElement("div"); root = createRoot(container); }); +afterEach(() => act(() => root.unmount())); +test("the Rotation control exposes four clockwise values and emits a number", () => { + const change = vi.fn(); + act(() => root.render()); + const select = container.querySelector("select")!; + expect(container.querySelector("label")!.htmlFor).toBe(select.id); + expect(select.value).toBe("180"); + expect(Array.from(select.options).map((option) => option.text)).toEqual(["0° clockwise", "90° clockwise", "180° clockwise", "270° clockwise"]); + act(() => { select.value = "90"; select.dispatchEvent(new Event("change", { bubbles: true })); }); + expect(change).toHaveBeenCalledWith(90); +}); +test("a quarter turn fills the rotated recorded frame", () => { + act(() => root.render()); + const video = container.querySelector("video")!, wrapper = video.parentElement!; + expect(wrapper.style.aspectRatio).toBe("480 / 640"); + expect(video.style.objectFit).toBe("cover"); + expect(video.style.maxWidth).toBe("none"); + expect(video.style.transform).toBe("translate(-50%, -50%) rotate(90deg)"); + expect(parseFloat(video.style.width)).toBeCloseTo(640 / 480 * 100); + expect(parseFloat(video.style.height)).toBeCloseTo(480 / 640 * 100); +}); +test("a stream at another size keeps the recorded aspect ratio", () => { + act(() => root.render()); + const video = container.querySelector("video")!; + Object.defineProperty(video, "videoWidth", { value: 1280 }); + Object.defineProperty(video, "videoHeight", { value: 720 }); + act(() => video.dispatchEvent(new Event("loadedmetadata"))); + expect(video.parentElement!.style.aspectRatio).toBe("640 / 480"); + expect(video.style.width).toBe("100%"); + expect(video.style.objectFit).toBe("cover"); +}); +test("the reference ghost sits over the video with its rotation and opacity", () => { + act(() => root.render()); + const video = container.querySelector("video")!, ghost = container.querySelector("img")!; + expect(ghost.getAttribute("src")).toBe("blob:ref"); + expect(ghost.style.opacity).toBe("0.3"); + expect(ghost.style.transform).toBe(video.style.transform); + expect(ghost.style.width).toBe(video.style.width); + act(() => root.render()); + expect(container.querySelector("img")).toBeNull(); +}); diff --git a/src/components/control/BrowserCameraPreview.tsx b/src/components/control/BrowserCameraPreview.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b2b693345fe31f0076ae739d9fc30cc06be24544 --- /dev/null +++ b/src/components/control/BrowserCameraPreview.tsx @@ -0,0 +1,75 @@ +import React from "react"; +import { VideoOff } from "lucide-react"; +import { useCameraStream } from "@/hooks/useCameraStream"; +import { CameraRotation, cameraPreviewGeometry, cameraRotationError } from "@/lib/cameraConfig"; + +interface Props { + deviceId: string; + paused?: boolean; + rotation?: CameraRotation; + width?: number; + height?: number; + className?: string; + fallback?: string; + /** Saved reference frame, drawn over the live video as a translucent ghost. */ + ghostSrc?: string; + ghostOpacity?: number; +} + +/** + * Browser streams contain raw frames, so only browser previews use CSS rotation. + * The box keeps the recorded capture size's shape even when the browser stream + * has another size, so the preview frames the scene the way recording will. + */ +export default function BrowserCameraPreview({ + deviceId, + paused = false, + rotation = 0, + width = 640, + height = 480, + className = "", + fallback, + ghostSrc, + ghostOpacity = 0.5, +}: Props) { + const rotationError = cameraRotationError({ rotation }); + const { videoRef, hasError } = useCameraStream(deviceId, paused || !!rotationError, { width, height }); + const geometry = rotationError + ? { aspectRatio: `${width} / ${height}`, videoStyle: {} } + : cameraPreviewGeometry(width, height, rotation); + const showVideo = !rotationError && !paused && deviceId && !hasError; + return ( +
+ {showVideo ? ( + <> +
+ ); +} diff --git a/src/components/control/CameraFeed.tsx b/src/components/control/CameraFeed.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ccf3a333a4335ad5317e76cef07f443a9e8ccecc --- /dev/null +++ b/src/components/control/CameraFeed.tsx @@ -0,0 +1,22 @@ +import React from "react"; +import BrowserCameraPreview from "./BrowserCameraPreview"; +import { CameraRotation } from "@/lib/cameraConfig"; + +interface CameraFeedProps { + deviceId: string; + label?: string; + rotation?: CameraRotation; + width?: number; + height?: number; +} + +const CameraFeed: React.FC = ({ deviceId, label, rotation, width, height }) => ( +
+ + {label && ( +
{label}
+ )} +
+); + +export default CameraFeed; diff --git a/src/components/control/CameraRotationSelect.tsx b/src/components/control/CameraRotationSelect.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a664eb8623f5d4f8aec6cca6633aac0dc9e36eec --- /dev/null +++ b/src/components/control/CameraRotationSelect.tsx @@ -0,0 +1,30 @@ +import React, { useId } from "react"; +import { Label } from "@/components/ui/label"; +import { CAMERA_ROTATIONS, CameraRotation, cameraRotation } from "@/lib/cameraConfig"; + +interface Props { + rotation: CameraRotation | null; + onChange: (rotation: CameraRotation) => void; + disabled?: boolean; +} + +export default function CameraRotationSelect({ rotation, onChange, disabled }: Props) { + const id = useId(); + return ( +
+ + +
+ ); +} diff --git a/src/components/control/CommandBar.tsx b/src/components/control/CommandBar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ea5032d6d0330981e08f39b4ce460da6d1918024 --- /dev/null +++ b/src/components/control/CommandBar.tsx @@ -0,0 +1,81 @@ + +import React from 'react'; +import { Mic, MicOff, Send, Camera } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; + +interface CommandBarProps { + command: string; + setCommand: (command: string) => void; + handleSendCommand: () => void; + isVoiceActive: boolean; + setIsVoiceActive: (isActive: boolean) => void; + showCamera: boolean; + setShowCamera: (show: boolean) => void; + handleEndSession: () => void; +} + +const CommandBar: React.FC = ({ + command, + setCommand, + handleSendCommand, + isVoiceActive, + setIsVoiceActive, + showCamera, + setShowCamera, + handleEndSession +}) => { + return ( +
+
+ setCommand(e.target.value)} + placeholder="Tell the robot what to do..." + className="flex-1 bg-gray-800 border-gray-600 text-white placeholder-gray-400 text-lg py-3" + onKeyPress={(e) => e.key === 'Enter' && handleSendCommand()} + /> + +
+ +
+
+ + + + + +
+
+
+ ); +}; + +export default CommandBar; diff --git a/src/components/control/MetricsPanel.tsx b/src/components/control/MetricsPanel.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d66d6c63feacc604de91708e6c0f103cd103f21a --- /dev/null +++ b/src/components/control/MetricsPanel.tsx @@ -0,0 +1,190 @@ + +import React, { useEffect, useRef } from 'react'; +import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'; +import { Camera, MicOff } from 'lucide-react'; + +interface MetricsPanelProps { + activeTab: 'SENSORS' | 'MOTORS'; + setActiveTab: (tab: 'SENSORS' | 'MOTORS') => void; + sensorData: any[]; + motorData: any[]; + hasPermissions: boolean; + streamRef: React.RefObject; + isVoiceActive: boolean; + micLevel: number; +} + +const MetricsPanel: React.FC = ({ + activeTab, + setActiveTab, + sensorData, + motorData, + hasPermissions, + streamRef, + isVoiceActive, + micLevel, +}) => { + const sensorVideoRef = useRef(null); + + useEffect(() => { + if (activeTab === 'SENSORS' && hasPermissions && sensorVideoRef.current && streamRef.current) { + if (sensorVideoRef.current.srcObject !== streamRef.current) { + sensorVideoRef.current.srcObject = streamRef.current; + } + } + }, [activeTab, hasPermissions, streamRef]); + + return ( +
+
+ {/* Tab Headers */} +
+ + +
+ + {/* Chart Content */} +
+ {activeTab === 'SENSORS' && ( +
+ {/* Webcam Feed */} +
+

Live Camera Feed

+ {hasPermissions ? ( +
+
+ ) : ( +
+
+ +

Camera permission not granted.

+
+
+ )} +
+ + {/* Mic Detection & Other Sensors */} +
+
+

Voice Activity

+ {hasPermissions ? ( +
+
+ {[...Array(15)].map((_, i) => { + const barIsActive = isVoiceActive && i < (micLevel / 120 * 15); + return ( +
+ ); + })} +
+

+ {isVoiceActive ? "Voice commands active" : "Voice commands muted"} +

+
+ ) : ( +
+
+ +

Microphone permission not granted.

+
+
+ )} +
+ + {/* Sensor Charts */} + {['sensor3', 'sensor4'].map((sensor, index) => ( +
+

Sensor {index + 3}

+ + + + + + + + + +
+ ))} +
+
+ )} + + {activeTab === 'MOTORS' && ( +
+ {['motor1', 'motor2', 'motor3', 'motor4', 'motor5', 'motor6'].map((motor, index) => ( +
+

Motor {index + 1}

+ + + + + + + + + +
+ ))} +
+ )} +
+
+
+ ); +}; + +export default MetricsPanel; diff --git a/src/components/control/RecordingCameraFeed.test.tsx b/src/components/control/RecordingCameraFeed.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a000493341cd931a2c32f02361a31aabccb45b20 --- /dev/null +++ b/src/components/control/RecordingCameraFeed.test.tsx @@ -0,0 +1,27 @@ +// @vitest-environment jsdom +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { expect, test } from "vitest"; +import RecordingCameraFeed from "./RecordingCameraFeed"; +import { recordingCameraWindows } from "@/lib/cameraConfig"; + +test("recording feeds use each camera's output ratio without rotating corrected backend frames", () => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + const windows = recordingCameraWindows(["front", "wrist"], { + front: { type: "opencv", width: 640, height: 480, rotation: 0 }, + wrist: { type: "opencv", width: 640, height: 480, rotation: 90 }, + }, { w: 1000, h: 600 }); + const container = document.createElement("div"), root = createRoot(container); + act(() => root.render(<>{Object.entries(windows).map(([name, dimensions]) => ( + + ))})); + const images = Array.from(container.querySelectorAll("img")); + expect(images).toHaveLength(2); + expect(parseFloat(images[0].parentElement!.style.width) / parseFloat(images[0].parentElement!.style.height)).toBeCloseTo(4 / 3, 2); + expect(parseFloat(images[1].parentElement!.style.width) / parseFloat(images[1].parentElement!.style.height)).toBeCloseTo(3 / 4, 2); + for (const image of images) { + expect(image.className).toContain("object-contain"); + expect(image.style.transform).toBe(""); + } + act(() => root.unmount()); +}); diff --git a/src/components/control/RecordingCameraFeed.tsx b/src/components/control/RecordingCameraFeed.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ec6b3bf8db7b70a98fd317f2ea7e9b11744f4ae2 --- /dev/null +++ b/src/components/control/RecordingCameraFeed.tsx @@ -0,0 +1,75 @@ +import React, { useState, useEffect, useRef, useCallback } from "react"; + +interface CameraFeedProps { + baseUrl: string; + name: string; + // False during the preparing phase: show an empty slot until the camera is + // connected and streaming. True once recording/resetting, when frames flow. + live: boolean; + // Pixel size computed by the parent to fit the available space. The box is + // sized to the camera's aspect ratio, so the video fills it without letterbox. + width: number; + height: number; +} + +// Renders one recording camera's window at an explicit size. During preparing it +// shows an empty placeholder; once live it plays the backend MJPEG stream. The +// browser renders a `multipart/x-mixed-replace` response natively in an , +// so we just point it at /camera-feed/{name}. If the stream errors before frames +// flow (camera still warming up), retry with a cache-busting key after a delay. +const RecordingCameraFeed: React.FC = ({ + baseUrl, + name, + live, + width, + height, +}) => { + const [reloadKey, setReloadKey] = useState(0); + const [hasError, setHasError] = useState(false); + const retryRef = useRef(null); + + const src = `${baseUrl}/camera-feed/${encodeURIComponent(name)}?k=${reloadKey}`; + + useEffect(() => { + return () => { + if (retryRef.current) window.clearTimeout(retryRef.current); + }; + }, []); + + const handleError = useCallback(() => { + setHasError(true); + if (retryRef.current) window.clearTimeout(retryRef.current); + retryRef.current = window.setTimeout(() => { + setHasError(false); + setReloadKey((k) => k + 1); + }, 1500); + }, []); + + // 0 before the first measurement; skip rendering a zero-size box. + if (width <= 0 || height <= 0) return null; + + return ( +
+ {!live ? ( + Getting ready… + ) : hasError ? ( + Connecting feed… + ) : ( + {`${name} + )} + + {name} + +
+ ); +}; + +export default RecordingCameraFeed; diff --git a/src/components/control/TeleopCameraPanel.tsx b/src/components/control/TeleopCameraPanel.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4e61377dcd1c23d95fd70b852f85ab93030acbfa --- /dev/null +++ b/src/components/control/TeleopCameraPanel.tsx @@ -0,0 +1,104 @@ +import React, { useState } from "react"; +import { RefreshCw } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { useRobots } from "@/hooks/useRobots"; +import { useAvailableCameras } from "@/hooks/useAvailableCameras"; +import { previewDeviceId } from "@/lib/cameraConfig"; +import CameraFeed from "./CameraFeed"; + +/** + * Optional live camera panel for the teleoperation page. Off by default so we + * never call getUserMedia just by landing on the page (same consent pattern as + * the calibration camera toggle). Teleoperation opens no cv2 cameras, so the + * browser can stream them directly while the arm runs. + * + * A strict mirror of the selected robot's configured cameras: one live feed per + * camera on the robot record (e.g. "wrist_cam", "webcam"), stacked vertically. + * If the robot has none configured it shows nothing — teleop never surfaces a + * device that wasn't deliberately added to the robot. + */ +const TeleopCameraPanel: React.FC = () => { + const [enabled, setEnabled] = useState(false); + // Bumped by the retry button to remount the feeds (a fresh getUserMedia + // attempt) — useful if a camera was unplugged and reconnected. + const [reloadKey, setReloadKey] = useState(0); + const { selectedRecord, isLoading: robotsLoading } = useRobots(); + const { cameras: availableCameras } = useAvailableCameras({ enabled }); + + // Feeds come solely from the robot's configured cameras, streamed by their + // stored browser device_id or, in a browser that doesn't know that ID, by + // their cv2 index. A configured camera whose device is currently absent still + // shows (name + failed-preview placeholder), so the user can tell it's + // expected but not detected. + const configured = selectedRecord?.cameras ?? []; + const feeds = configured.map((c) => ({ + key: c.id, + name: c.name, + deviceId: previewDeviceId(c, availableCameras), + rotation: c.rotation, + width: c.width, + height: c.height, + })); + + return ( +
+
+

Cameras

+
+ {enabled && feeds.length > 0 && ( + + )} + + +
+
+ + {enabled ? ( + feeds.length > 0 ? ( +
+ {feeds.map((feed) => ( + + ))} +
+ ) : ( +

+ {robotsLoading + ? "Loading robot..." + : "No cameras configured for this robot. Add them during calibration to see live feeds here."} +

+ ) + ) : ( +

+ Turn on to watch your cameras while you teleoperate. +

+ )} +
+ ); +}; + +export default TeleopCameraPanel; diff --git a/src/components/control/VisualizerPanel.tsx b/src/components/control/VisualizerPanel.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9dca7861db3cc1e0cf8def14d85bdcc0083859d1 --- /dev/null +++ b/src/components/control/VisualizerPanel.tsx @@ -0,0 +1,52 @@ +import React from "react"; +import { Button } from "@/components/ui/button"; +import { ArrowLeft } from "lucide-react"; +import { cn } from "@/lib/utils"; +import UrdfViewer from "../UrdfViewer"; +import Logo from "@/components/Logo"; + +interface VisualizerPanelProps { + onGoBack: () => void; + className?: string; + /** Optional content rendered as a column beside the 3D viewer (e.g. a camera panel). */ + rightSlot?: React.ReactNode; +} + +const VisualizerPanel: React.FC = ({ + onGoBack, + className, + rightSlot, +}) => { + return ( +
+
+
+ + +
+

Teleoperation

+
+
+ +
+
+ {rightSlot && ( +
{rightSlot}
+ )} +
+ ); +}; + +export default VisualizerPanel; diff --git a/src/components/dataset/DeleteDatasetDialog.test.tsx b/src/components/dataset/DeleteDatasetDialog.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..fe87aca510f9e6be6444cabf78547e37dd6a03b0 --- /dev/null +++ b/src/components/dataset/DeleteDatasetDialog.test.tsx @@ -0,0 +1,66 @@ +// @vitest-environment jsdom +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { expect, test, vi } from "vitest"; + +import DeleteDatasetDialog from "./DeleteDatasetDialog"; + +const fetchWithHeaders = vi.fn(async (_url: string, _init: { body: string }) => ({ ok: true, json: async () => ({ success: true }) })); + +vi.mock("@/contexts/ApiContext", () => ({ + useApi: () => ({ baseUrl: "http://api", fetchWithHeaders }), +})); +vi.mock("@/hooks/use-toast", () => ({ useToast: () => ({ toast: () => {} }) })); + +const render = async (onHub: boolean, onDeleted = () => {}) => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + await act(async () => { + root.render( + {}} + onDeleted={onDeleted} + />, + ); + }); + return root; +}; + +const deleteButton = () => + [...document.querySelectorAll("button")].find((b) => b.textContent === "Delete")!; + +test("a local-only dataset offers no Hub checkbox and deletes locally", async () => { + fetchWithHeaders.mockClear(); + const onDeleted = vi.fn(); + const root = await render(false, onDeleted); + expect(document.body.textContent).not.toContain("Also delete this dataset from the HuggingFace Hub"); + + await act(async () => deleteButton().click()); + + expect(JSON.parse(fetchWithHeaders.mock.calls[0][1].body)).toEqual({ + dataset_repo_id: "me/demo", + delete_from_hub: false, + }); + expect(onDeleted).toHaveBeenCalled(); + act(() => root.unmount()); +}); + +test("deleting from the Hub stays disabled until the repo id is typed", async () => { + const root = await render(true); + const checkbox = document.querySelector("#delete-from-hub")!; + await act(async () => checkbox.click()); + expect(deleteButton().disabled).toBe(true); + + const input = document.querySelector("input")!; + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, "me/demo"); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + expect(deleteButton().disabled).toBe(false); + act(() => root.unmount()); +}); diff --git a/src/components/dataset/DeleteDatasetDialog.tsx b/src/components/dataset/DeleteDatasetDialog.tsx new file mode 100644 index 0000000000000000000000000000000000000000..bd77f6b0945342da04de4bf492db72738507be12 --- /dev/null +++ b/src/components/dataset/DeleteDatasetDialog.tsx @@ -0,0 +1,139 @@ +import React, { useState } from "react"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { useApi } from "@/contexts/ApiContext"; +import { useToast } from "@/hooks/use-toast"; + +interface DeleteDatasetDialogProps { + repoId: string; + onHub: boolean; + open: boolean; + onOpenChange: (open: boolean) => void; + onDeleted: () => void; +} + +/** Confirms and performs the removal of a local dataset, optionally from the Hub too. */ +const DeleteDatasetDialog: React.FC = ({ + repoId, + onHub, + open, + onOpenChange, + onDeleted, +}) => { + const { baseUrl, fetchWithHeaders } = useApi(); + const { toast } = useToast(); + const [isDeleting, setIsDeleting] = useState(false); + const [deleteFromHub, setDeleteFromHub] = useState(false); + const [confirmText, setConfirmText] = useState(""); + + const reset = () => { + setDeleteFromHub(false); + setConfirmText(""); + }; + + const handleDelete = async () => { + setIsDeleting(true); + try { + const response = await fetchWithHeaders(`${baseUrl}/delete-dataset`, { + method: "POST", + body: JSON.stringify({ dataset_repo_id: repoId, delete_from_hub: deleteFromHub }), + }); + const data = await response.json(); + if (response.ok && data.success) { + toast({ + title: "Dataset Deleted", + description: deleteFromHub + ? `${repoId} has been removed from disk and the Hub.` + : `${repoId} has been removed from disk.`, + }); + onDeleted(); + } else { + toast({ + title: "Delete Failed", + description: data.message || "Could not delete the dataset.", + variant: "destructive", + }); + } + } catch { + toast({ + title: "Connection Error", + description: "Could not connect to the backend server.", + variant: "destructive", + }); + } finally { + setIsDeleting(false); + onOpenChange(false); + reset(); + } + }; + + return ( + { + onOpenChange(next); + if (!next) reset(); + }} + > + + + Delete dataset from disk? + + This permanently removes {repoId} from your local cache. This action cannot be undone. + + + {onHub && ( +
+
+ setDeleteFromHub(checked === true)} + /> + +
+ {deleteFromHub && ( +
+ + setConfirmText(e.target.value)} + className="bg-gray-800 border-gray-700 text-white" + /> +
+ )} +
+ )} + + + Keep dataset + + + {isDeleting ? "Deleting…" : "Delete"} + + +
+
+ ); +}; + +export default DeleteDatasetDialog; diff --git a/src/components/dataset/EpisodeList.tsx b/src/components/dataset/EpisodeList.tsx new file mode 100644 index 0000000000000000000000000000000000000000..36186b60acfbb144d7997c77334c65b7654cdfb1 --- /dev/null +++ b/src/components/dataset/EpisodeList.tsx @@ -0,0 +1,89 @@ +import React, { useEffect, useRef } from "react"; +import { Pencil } from "lucide-react"; +import { EpisodeSummary, formatDuration } from "@/lib/datasetApi"; + +interface EpisodeListProps { + episodes: EpisodeSummary[]; + selected: number | null; + onSelect: (episodeIndex: number) => void; + /** Shows an edit button on the selected episode's task when set. */ + onEditTask?: (episodeIndex: number) => void; +} + +const EpisodeList: React.FC = ({ episodes, selected, onSelect, onEditTask }) => { + const activeRef = useRef(null); + + // Follow the selection. Paging with Previous/Next, or opening a link straight + // to episode 40, would otherwise leave the highlighted row scrolled out of + // sight — the list would look like it never moved. + useEffect(() => { + activeRef.current?.scrollIntoView({ block: "nearest" }); + }, [selected]); + + if (episodes.length === 0) { + return ( +
+ No episodes in this dataset. +
+ ); + } + + return ( +
+
+ + Episodes + + {episodes.length} +
+ {/* Tailwind arbitrary values take underscores where the CSS needs spaces: + `calc(100vh-280px)` is not valid CSS and the rule is dropped. */} +
+ {episodes.map((ep) => { + const active = ep.episode_index === selected; + return ( +
+ + {active && onEditTask && ( + + )} +
+ ); + })} +
+
+ ); +}; + +export default EpisodeList; diff --git a/src/components/dataset/EpisodeTaskDialog.test.tsx b/src/components/dataset/EpisodeTaskDialog.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9b1e34f47fc869e354a750f6bfc882791ae02218 --- /dev/null +++ b/src/components/dataset/EpisodeTaskDialog.test.tsx @@ -0,0 +1,61 @@ +// @vitest-environment jsdom +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { expect, test } from "vitest"; + +import EpisodeTaskDialog from "./EpisodeTaskDialog"; + +test("saves the task for the browsed episode or for every episode", async () => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + const saved: [string, number | null][] = []; + + try { + await act(async () => { + root.render( + {}} + onSave={(task, episodeIndex) => { + saved.push([task, episodeIndex]); + }} + />, + ); + }); + + const input = document.getElementById("episode-task") as HTMLInputElement; + const saveButton = () => + [...document.body.querySelectorAll("button")].find( + (b) => b.textContent === "Save task", + ) as HTMLButtonElement; + const type = async (value: string) => { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!; + await act(async () => { + setter.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + }; + + expect(input.value).toBe("pick the cube"); + + await type(" "); + expect(saveButton().disabled).toBe(true); + + await type(" pick the bowl "); + await act(async () => saveButton().click()); + expect(saved).toEqual([["pick the bowl", 3]]); + + await act(async () => (document.getElementById("episode-task-all") as HTMLButtonElement).click()); + await act(async () => saveButton().click()); + expect(saved.at(-1)).toEqual(["pick the bowl", null]); + } finally { + await act(async () => root.unmount()); + host.remove(); + } +}); diff --git a/src/components/dataset/EpisodeTaskDialog.tsx b/src/components/dataset/EpisodeTaskDialog.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8ca08ecb6154f686896c9d173fc5b1ee9a72a4aa --- /dev/null +++ b/src/components/dataset/EpisodeTaskDialog.tsx @@ -0,0 +1,106 @@ +import { useEffect, useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +interface EpisodeTaskDialogProps { + repoId: string; + episodeIndex: number; + currentTask: string; + totalEpisodes: number; + open: boolean; + onOpenChange: (open: boolean) => void; + /** `episodeIndex` is null when the task applies to every episode. */ + onSave: (task: string, episodeIndex: number | null) => void; +} + +export default function EpisodeTaskDialog({ + repoId, + episodeIndex, + currentTask, + totalEpisodes, + open, + onOpenChange, + onSave, +}: EpisodeTaskDialogProps) { + const [task, setTask] = useState(""); + const [allEpisodes, setAllEpisodes] = useState(false); + + useEffect(() => { + if (open) { + setTask(currentTask); + setAllEpisodes(false); + } + }, [open, currentTask]); + + const canSave = task.trim() !== ""; + + const handleSave = () => { + if (!canSave) return; + onSave(task.trim(), allEpisodes ? null : episodeIndex); + onOpenChange(false); + }; + + return ( + + + + Edit task + + The task is the instruction a policy is trained on, saved with each episode of{" "} + {repoId}. + + + +
+
+ + setTask(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") handleSave(); + }} + className="border-gray-700 bg-gray-800 text-white" + /> +
+ {totalEpisodes > 1 && ( +
+ setAllEpisodes(checked === true)} + className="border-gray-500" + /> + +
+ )} +
+ + + + + +
+
+ ); +} diff --git a/src/components/dataset/EpisodeViewer.test.tsx b/src/components/dataset/EpisodeViewer.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c8b3f8576e76da29af3994c124b35fbc88f30170 --- /dev/null +++ b/src/components/dataset/EpisodeViewer.test.tsx @@ -0,0 +1,52 @@ +// @vitest-environment jsdom +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { expect, test, vi } from "vitest"; +import EpisodeViewer from "./EpisodeViewer"; +import { EpisodeDetail } from "@/lib/datasetApi"; + +vi.mock("@/contexts/ApiContext", () => ({ + useApi: () => ({ baseUrl: "http://localhost:8000" }), +})); +vi.mock("./FilmStrip", () => ({ default: () => null })); +vi.mock("./MotionTrace", () => ({ default: () => null })); +vi.mock("./ReplayButton", () => ({ default: () => null })); + +test.each([0, 4])("playback loops at file end with an episode offset of %s seconds", async (offset) => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + const host = document.createElement("div"); + const root = createRoot(host); + const detail: EpisodeDetail = { + success: true, repo_id: "owner/demo", episode_index: 1, + length: 10, duration_s: 1, tasks: [], fps: 10, robot_type: "test", + cameras: [{ name: "top", chunk: 0, file_index: 0, + from_timestamp: offset, to_timestamp: offset + 1 }], + prev_episode: 0, next_episode: null, total_episodes: 2, + }; + act(() => root.render( {}} />)); + const video = host.querySelector("video")!; + let paused = false; + let ended = false; + Object.defineProperties(video, { + paused: { get: () => paused }, + ended: { get: () => ended }, + }); + video.play = vi.fn(async () => { paused = false; }); + try { + act(() => video.dispatchEvent(new Event("play"))); + // At natural EOF the browser is already paused when timeupdate runs, + // then delivers pause and ended. Seeking alone does not resume playback. + paused = true; + ended = true; + video.currentTime = offset + 1; + await act(async () => { + for (const type of ["timeupdate", "pause", "ended"]) { + video.dispatchEvent(new Event(type)); + } + }); + expect(video.currentTime).toBe(offset); + expect(paused).toBe(false); + } finally { + act(() => root.unmount()); + } +}); diff --git a/src/components/dataset/EpisodeViewer.tsx b/src/components/dataset/EpisodeViewer.tsx new file mode 100644 index 0000000000000000000000000000000000000000..655f3fc5dfe7190a818ace8e356d0e0f92c43c70 --- /dev/null +++ b/src/components/dataset/EpisodeViewer.tsx @@ -0,0 +1,435 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + ChevronLeft, + ChevronRight, + Grid2X2, + Loader2, + Maximize2, + Pause, + Play, + SkipBack, + SkipForward, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { useApi } from "@/contexts/ApiContext"; +import { EpisodeDetail, frameUrl, videoUrl } from "@/lib/datasetApi"; +import FilmStrip from "./FilmStrip"; +import MotionTrace from "./MotionTrace"; +import ReplayButton from "./ReplayButton"; + +const RATES = [0.5, 1, 2] as const; +type Rate = (typeof RATES)[number]; + +/** + * Secondaries are nudged only when they drift past this. Chasing every + * timeupdate would thrash the decoder for sub-frame corrections nobody sees. + */ +const SYNC_DEADBAND_S = 0.2; + +interface EpisodeViewerProps { + repoId: string; + detail: EpisodeDetail; + onNavigate: (episodeIndex: number) => void; +} + +const EpisodeViewer: React.FC = ({ repoId, detail, onNavigate }) => { + const { baseUrl } = useApi(); + const cams = detail.cameras; + + const [activeCam, setActiveCam] = useState(cams[0]?.name ?? ""); + const [showAll, setShowAll] = useState(false); + const [playing, setPlaying] = useState(false); + const [rate, setRate] = useState(1); + const [currentFrame, setCurrentFrame] = useState(0); + + const driverRef = useRef(null); + const secondaryRefs = useRef>({}); + + // Episode changed (or its cameras did) → fall back to a camera that exists. + useEffect(() => { + if (!cams.some((c) => c.name === activeCam)) setActiveCam(cams[0]?.name ?? ""); + }, [cams, activeCam]); + + const activeInfo = useMemo( + () => cams.find((c) => c.name === activeCam) ?? cams[0] ?? null, + [cams, activeCam], + ); + + const fps = detail.fps ?? 20; + // The mp4 holds several episodes back to back; this one occupies + // [fromTs, toTs). Everything below is expressed against that window. + const fromTs = activeInfo?.from_timestamp ?? 0; + const toTs = activeInfo?.to_timestamp ?? fromTs + (detail.duration_s ?? 0); + + const seekToFrame = useCallback( + (frameIndex: number) => { + const v = driverRef.current; + if (!v) return; + const clamped = Math.max(0, Math.min(frameIndex, Math.max(detail.length - 1, 0))); + // Seek to the frame's CENTER, not its boundary. A boundary seek sits + // epsilon away from `clamped / fps`, so the timeupdate recompute + // (floor((t - fromTs) * fps)) can floor to the previous frame: stepping + // right went +1 then snapped back, stepping left jumped 2. Mid-frame, + // floor(clamped + 0.5) is immune to float error in either direction. + v.currentTime = fromTs + (clamped + 0.5) / fps; + setCurrentFrame(clamped); + }, + [fromTs, fps, detail.length], + ); + + const handleLoadedMetadata = useCallback(() => { + const v = driverRef.current; + if (!v) return; + v.currentTime = fromTs; + // A fresh element resets playbackRate to 1, which used to leave the pills + // reading "2x" while playback ran at 1x. + v.playbackRate = rate; + setCurrentFrame(0); + }, [fromTs, rate]); + + // Move to the new episode's window when the selection changes. + // + // The element is keyed on the *file*, so paging between episodes that share + // an mp4 doesn't remount it and `loadedMetadata` won't fire again — this is + // what actually puts the player on the new episode. Keying on the episode + // instead would rebuild the player every time, which silently drops playback + // (a new element is always paused) and leaves a frozen frame behind. + useEffect(() => { + const v = driverRef.current; + if (!v) return; + const wasPlaying = !v.paused; + const goToEpisode = () => { + v.currentTime = fromTs; + v.playbackRate = rate; + setCurrentFrame(0); + // Keep playing across the switch rather than stranding the user on a + // still frame with the button claiming it's playing. + if (wasPlaying || playing) void v.play().catch(() => setPlaying(false)); + }; + if (v.readyState >= 1) { + goToEpisode(); + } else { + v.addEventListener("loadedmetadata", goToEpisode, { once: true }); + return () => v.removeEventListener("loadedmetadata", goToEpisode); + } + // `playing` is deliberately not a dep: this runs on episode/camera change, + // not every play/pause. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [detail.episode_index, activeCam, fromTs]); + + const handleTimeUpdate = useCallback(() => { + const v = driverRef.current; + if (!v) return; + // Loop inside this episode's window rather than running on into the next + // episode's footage, which shares the file. + // At file end, let onEnded rewind and resume after the browser pauses. + if (v.ended) return; + if (v.currentTime >= toTs) { + v.currentTime = fromTs; + setCurrentFrame(0); + return; + } + setCurrentFrame(Math.max(0, Math.floor((v.currentTime - fromTs) * fps))); + + if (!showAll) return; + // Each camera sits at its own offset inside its own file, so a secondary is + // synced by mapping through *its* from_timestamp — not by copying + // currentTime, which would land it in a neighbouring episode. + for (const c of cams) { + if (c.name === activeCam) continue; + const el = secondaryRefs.current[c.name]; + if (!el) continue; + const target = (c.from_timestamp ?? 0) + (v.currentTime - fromTs); + if (Math.abs(el.currentTime - target) > SYNC_DEADBAND_S) el.currentTime = target; + el.playbackRate = v.playbackRate; + if (playing && el.paused) void el.play().catch(() => undefined); + if (!playing && !el.paused) el.pause(); + } + }, [toTs, fromTs, fps, showAll, cams, activeCam, playing]); + + const togglePlay = useCallback(() => { + const v = driverRef.current; + if (!v) return; + if (v.paused) { + void v.play().then( + () => setPlaying(true), + () => setPlaying(false), + ); + } else { + v.pause(); + setPlaying(false); + } + }, []); + + // The arm replays at the recording's speed; run the footage alongside it. + const playFromStart = useCallback(() => { + const v = driverRef.current; + if (!v) return; + setRate(1); + seekToFrame(0); + void v.play().then( + () => setPlaying(true), + () => setPlaying(false), + ); + }, [seekToFrame]); + + const pauseFootage = useCallback(() => { + driverRef.current?.pause(); + setPlaying(false); + }, []); + + // Keep the element's rate in step with the pills across re-renders and + // camera swaps. + useEffect(() => { + if (driverRef.current) driverRef.current.playbackRate = rate; + }, [rate, activeCam]); + + // Space toggles playback, except while typing in a field. + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + const el = e.target as HTMLElement | null; + if (el && ["INPUT", "TEXTAREA", "SELECT"].includes(el.tagName)) return; + if (e.code === "Space") { + e.preventDefault(); + togglePlay(); + } else if (e.code === "ArrowLeft") { + seekToFrame(currentFrame - 1); + } else if (e.code === "ArrowRight") { + seekToFrame(currentFrame + 1); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [togglePlay, seekToFrame, currentFrame]); + + if (!activeInfo) { + return ( +
+ This episode has no camera videos on disk. +
+ ); + } + + const secondaries = cams.filter((c) => c.name !== activeCam); + + return ( +
+ {/* Camera + view controls */} +
+
+ {cams.map((c) => ( + + ))} +
+ + {cams.length > 1 && ( + + )} + +
+ +
+ {RATES.map((r) => ( + + ))} +
+
+
+ + {/* Viewport */} +
1 + ? "grid grid-cols-1 gap-2 sm:grid-cols-2" + : "grid grid-cols-1" + } + > +
+
+ + {showAll && + secondaries.map((c) => ( +
+
+ ))} +
+ + {/* Transport */} +
+ + + + + seekToFrame(Number(e.target.value))} + className="mx-2 h-1 flex-1 cursor-pointer appearance-none rounded bg-gray-800 accent-orange-500" + aria-label="Scrub episode" + /> + + + {currentFrame} / {Math.max(detail.length - 1, 0)} + +
+ + + + + + {/* Episode paging */} +
+ + + Episode {detail.episode_index} of {detail.total_episodes} + + +
+
+ ); +}; + +export default EpisodeViewer; diff --git a/src/components/dataset/FilmStrip.tsx b/src/components/dataset/FilmStrip.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9475aa24eb3196b9a74627fd95117c9fdb25e956 --- /dev/null +++ b/src/components/dataset/FilmStrip.tsx @@ -0,0 +1,105 @@ +import React, { useEffect, useState } from "react"; +import { Loader2 } from "lucide-react"; +import { useApi } from "@/contexts/ApiContext"; +import { Thumbnail, getThumbnails } from "@/lib/datasetApi"; + +interface FilmStripProps { + repoId: string; + episodeIndex: number; + camera: string; + /** Episode-relative frame currently on screen; the nearest thumb highlights. */ + currentFrame: number; + onSeekFrame: (frameIndex: number) => void; + count?: number; +} + +/** + * Thumbnails across the episode. One request for the whole strip — the backend + * decodes them in a single pass over the mp4 rather than reopening it per frame. + */ +const FilmStrip: React.FC = ({ + repoId, + episodeIndex, + camera, + currentFrame, + onSeekFrame, + count = 12, +}) => { + const { baseUrl, fetchWithHeaders } = useApi(); + const [thumbs, setThumbs] = useState([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + const controller = new AbortController(); + setLoading(true); + getThumbnails(baseUrl, fetchWithHeaders, repoId, episodeIndex, camera, count, controller.signal) + .then((r) => { + if (controller.signal.aborted) return; + setThumbs(r.success ? r.thumbnails : []); + }) + .catch(() => { + if (!controller.signal.aborted) setThumbs([]); + }) + .finally(() => { + if (!controller.signal.aborted) setLoading(false); + }); + return () => controller.abort(); + }, [baseUrl, fetchWithHeaders, repoId, episodeIndex, camera, count]); + + // Highlight the thumb nearest the playhead rather than the one before it, so + // the marker tracks what's actually on screen. + const activeIdx = thumbs.reduce( + (best, t, i) => + Math.abs(t.frame_index - currentFrame) < + Math.abs((thumbs[best]?.frame_index ?? Infinity) - currentFrame) + ? i + : best, + 0, + ); + + if (loading && thumbs.length === 0) { + return ( +
+ +
+ ); + } + + if (thumbs.length === 0) { + return ( +
+ No thumbnails +
+ ); + } + + return ( +
+ {thumbs.map((t, i) => ( + + ))} +
+ ); +}; + +export default FilmStrip; diff --git a/src/components/dataset/MergeDatasetsDialog.test.tsx b/src/components/dataset/MergeDatasetsDialog.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a94ffb30f195657440958f82d39c0cbb4a4e520b --- /dev/null +++ b/src/components/dataset/MergeDatasetsDialog.test.tsx @@ -0,0 +1,60 @@ +// @vitest-environment jsdom +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { expect, test } from "vitest"; + +import MergeDatasetsDialog from "./MergeDatasetsDialog"; + +test("opens with the browsed local dataset retained as the first merge source", async () => { + Object.assign(globalThis, { + IS_REACT_ACT_ENVIRONMENT: true, + ResizeObserver: class { + observe() {} + unobserve() {} + disconnect() {} + }, + }); + Element.prototype.scrollIntoView = () => {}; + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + + try { + await act(async () => { + root.render( + {}} + onMerge={async () => {}} + />, + ); + }); + + expect(document.body.textContent).toContain("local/browsed is included"); + expect(document.body.textContent).not.toContain("local/additional"); + expect(document.body.textContent).toContain("Select at least one additional dataset."); + const mergeButton = [...document.body.querySelectorAll("button")].find((button) => + button.textContent?.includes("Merge datasets"), + ) as HTMLButtonElement; + expect(mergeButton.disabled).toBe(true); + + const addButton = [...document.body.querySelectorAll("button")].find((button) => + button.textContent?.includes("Add dataset"), + ) as HTMLButtonElement; + await act(async () => { + addButton.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true })); + addButton.click(); + }); + expect(document.body.querySelector("[cmdk-input]")).not.toBeNull(); + expect(document.body.textContent).toContain("local/additional"); + expect(document.body.textContent).not.toContain("Create new dataset"); + } finally { + await act(async () => root.unmount()); + host.remove(); + } +}); diff --git a/src/components/dataset/MergeDatasetsDialog.tsx b/src/components/dataset/MergeDatasetsDialog.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e1b2e6240f8e19d1a85e113840cb79bc492052dd --- /dev/null +++ b/src/components/dataset/MergeDatasetsDialog.tsx @@ -0,0 +1,169 @@ +import { useEffect, useMemo, useState } from "react"; +import { Loader2, Plus, X } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import DatasetPicker from "@/components/landing/DatasetPicker"; +import { DatasetItem } from "@/lib/replayApi"; + +interface MergeDatasetsDialogProps { + datasets: DatasetItem[]; + selectedRepoId: string; + open: boolean; + onOpenChange: (open: boolean) => void; + onMerge: (sourceRepoIds: string[], outputName: string) => Promise; +} + +export default function MergeDatasetsDialog({ + datasets, + selectedRepoId, + open, + onOpenChange, + onMerge, +}: MergeDatasetsDialogProps) { + const [selected, setSelected] = useState([selectedRepoId]); + const [outputName, setOutputName] = useState(""); + const [merging, setMerging] = useState(false); + const [error, setError] = useState(null); + + const candidates = useMemo( + () => + datasets.filter( + (dataset) => + (dataset.source === "local" || dataset.source === "both") && !selected.includes(dataset.repo_id), + ), + [datasets, selected], + ); + const canMerge = selected.length >= 2 && /^[A-Za-z0-9._-]+$/.test(outputName) && !merging; + + const addDataset = (repoId: string) => { + setError(null); + setSelected((current) => [...current, repoId]); + }; + + const removeDataset = (repoId: string) => { + setError(null); + setSelected((current) => current.filter((id) => id !== repoId)); + }; + + const reset = () => { + setSelected([selectedRepoId]); + setOutputName(""); + setMerging(false); + setError(null); + }; + + useEffect(() => { + if (open) { + setSelected([selectedRepoId]); + setOutputName(""); + setMerging(false); + setError(null); + } + }, [open, selectedRepoId]); + + const handleOpenChange = (nextOpen: boolean) => { + if (!merging && !nextOpen) reset(); + onOpenChange(nextOpen); + }; + + const handleMerge = async () => { + if (!canMerge) return; + setMerging(true); + try { + await onMerge(selected, outputName); + reset(); + onOpenChange(false); + } catch (caughtError) { + setError(caughtError instanceof Error ? caughtError.message : "Could not merge the selected datasets."); + } finally { + setMerging(false); + } + }; + + return ( + + + + Merge local datasets + + {selectedRepoId} is included. Select at least one more local dataset. Their source files stay + unchanged; the merged dataset is saved locally. + + + +
+ +
+

{selectedRepoId}

+ {selected.slice(1).map((repoId) => ( +
+ {repoId} + +
+ ))} +
+ addDataset(item.repo_id)} + > + + +

Select at least one additional dataset.

+ +
+ + { + setError(null); + setOutputName(event.target.value.replace(/[^A-Za-z0-9._-]/g, "_")); + }} + placeholder="combined_pick_place" + disabled={merging} + className="border-gray-700 bg-gray-800 text-white" + /> +

+ Saved locally as local/{outputName || "name"}. +

+
+ {error &&

{error}

} +
+ + + + + +
+
+ ); +} diff --git a/src/components/dataset/MotionTrace.tsx b/src/components/dataset/MotionTrace.tsx new file mode 100644 index 0000000000000000000000000000000000000000..de3f50456c8699238112b731cb0678d0adbfe41e --- /dev/null +++ b/src/components/dataset/MotionTrace.tsx @@ -0,0 +1,134 @@ +import React, { useEffect, useMemo, useState } from "react"; +import { + Area, + AreaChart, + ReferenceLine, + ResponsiveContainer, + Tooltip, + YAxis, +} from "recharts"; +import { useApi } from "@/contexts/ApiContext"; +import { getMotion } from "@/lib/datasetApi"; + +interface MotionTraceProps { + repoId: string; + episodeIndex: number; + currentFrame: number; + onSeekFrame: (frameIndex: number) => void; +} + +/** + * Aggregate joint motion across the episode — one value per frame, summing the + * absolute change of every joint in the commanded action vector. + * + * Collapsing all joints into a single trace is the point: it makes the shape of + * a demonstration legible at a glance (idle head, the busy middle, idle tail) + * without asking anyone to read six overlapping lines. + */ +const MotionTrace: React.FC = ({ + repoId, + episodeIndex, + currentFrame, + onSeekFrame, +}) => { + const { baseUrl, fetchWithHeaders } = useApi(); + const [motion, setMotion] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + const controller = new AbortController(); + setError(null); + getMotion(baseUrl, fetchWithHeaders, repoId, episodeIndex, controller.signal) + .then((r) => { + if (controller.signal.aborted) return; + if (!r.success) { + setMotion(null); + setError(r.message ?? "No action stream"); + return; + } + setMotion(r.motion); + }) + .catch((e) => { + if (controller.signal.aborted) return; + setMotion(null); + setError(e instanceof Error ? e.message : "No action stream"); + }); + return () => controller.abort(); + }, [baseUrl, fetchWithHeaders, repoId, episodeIndex]); + + const data = useMemo( + () => (motion ?? []).map((v, i) => ({ frame: i, motion: v })), + [motion], + ); + + if (error) { + return ( +
+ Joint motion unavailable — {error} +
+ ); + } + + if (!motion) { + return
; + } + + return ( +
+
+ + Joint motion + + + frame {currentFrame} + +
+
+ + { + // activeLabel is the `frame` value under the cursor. + const f = e?.activeLabel; + if (f !== undefined && f !== null) onSeekFrame(Number(f)); + }} + > + + + + + + + + [v.toFixed(2), "motion"]} + labelFormatter={(l) => `Frame ${l}`} + /> + + + + +
+
+ ); +}; + +export default MotionTrace; diff --git a/src/components/dataset/MoveEpisodesDialog.test.tsx b/src/components/dataset/MoveEpisodesDialog.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..cbe095dec968b83800c93d61d8182bbe7c17a1d1 --- /dev/null +++ b/src/components/dataset/MoveEpisodesDialog.test.tsx @@ -0,0 +1,118 @@ +// @vitest-environment jsdom +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { expect, test } from "vitest"; + +import type { DatasetItem, MoveDestination } from "@/lib/replayApi"; + +import { parseEpisodeList } from "@/lib/datasetApi"; + +import MoveEpisodesDialog from "./MoveEpisodesDialog"; + +test("parses episode lists and ranges", () => { + expect(parseEpisodeList("3, 1, 5-7, 3", 10)).toEqual([1, 3, 5, 6, 7]); + expect(parseEpisodeList(" 2 - 4 ,", 10)).toEqual([2, 3, 4]); + expect(parseEpisodeList("", 10)).toBeNull(); + expect(parseEpisodeList("10", 10)).toBeNull(); + expect(parseEpisodeList("4-2", 10)).toBeNull(); + expect(parseEpisodeList("1, x", 10)).toBeNull(); +}); + +const DATASETS: DatasetItem[] = [ + { repo_id: "local/demo", source: "local", last_modified: "", private: false }, + { repo_id: "local/target", source: "local", last_modified: "", private: false }, +]; + +test("opens on the browsed episode and refuses to move every episode", async () => { + Object.assign(globalThis, { + IS_REACT_ACT_ENVIRONMENT: true, + ResizeObserver: class { + observe() {} + unobserve() {} + disconnect() {} + }, + }); + Element.prototype.scrollIntoView = () => {}; + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + const moved: [number[], MoveDestination][] = []; + + try { + await act(async () => { + root.render( + {}} + onMove={async (indices, destination) => { + moved.push([indices, destination]); + }} + />, + ); + }); + + const episodes = document.getElementById("move-episodes") as HTMLInputElement; + const moveButton = () => + [...document.body.querySelectorAll("button")].find((b) => + b.textContent?.startsWith("Move "), + ) as HTMLButtonElement; + const type = async (input: HTMLInputElement, value: string) => { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!; + await act(async () => { + setter.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + }; + const openPicker = async () => { + const trigger = [...document.body.querySelectorAll("button")].find((b) => + b.textContent?.includes("Select or create a dataset") || b.textContent?.startsWith("local/"), + ) as HTMLButtonElement; + await act(async () => { + trigger.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true })); + trigger.click(); + }); + }; + + expect(episodes.value).toBe("1"); + expect(moveButton().disabled).toBe(true); + + // The source never shows up as a destination. + await openPicker(); + const items = [...document.body.querySelectorAll("[cmdk-item]")].map((i) => i.textContent); + expect(items).toEqual(["local/target"]); + + // Create a new dataset by typing a name in the shared picker. + const search = document.body.querySelector("[cmdk-input]") as HTMLInputElement; + await type(search, "rejected takes"); + await act(async () => { + search.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + }); + expect(document.body.textContent).toContain("Saved locally as local/rejected_takes"); + expect(moveButton().disabled).toBe(false); + + await type(episodes, "0-1"); + expect(document.body.textContent).toContain("At least one episode must stay in the dataset."); + expect(moveButton().disabled).toBe(true); + + await type(episodes, "1"); + await act(async () => moveButton().click()); + expect(moved).toEqual([[[1], { newName: "rejected_takes" }]]); + + // Pick an existing dataset instead. + await openPicker(); + const target = [...document.body.querySelectorAll("[cmdk-item]")].find( + (i) => i.textContent === "local/target", + ) as HTMLElement; + await act(async () => target.click()); + expect(document.body.textContent).toContain("Merged into local/target"); + await act(async () => moveButton().click()); + expect(moved[1]).toEqual([[1], { existingRepoId: "local/target" }]); + } finally { + await act(async () => root.unmount()); + host.remove(); + } +}); diff --git a/src/components/dataset/MoveEpisodesDialog.tsx b/src/components/dataset/MoveEpisodesDialog.tsx new file mode 100644 index 0000000000000000000000000000000000000000..eaf27386118fa5c61265f7dc115997ebf496ce13 --- /dev/null +++ b/src/components/dataset/MoveEpisodesDialog.tsx @@ -0,0 +1,172 @@ +import { useEffect, useMemo, useState } from "react"; +import { ChevronsUpDown, Loader2 } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import DatasetPicker from "@/components/landing/DatasetPicker"; +import { parseEpisodeList } from "@/lib/datasetApi"; +import { DatasetItem, MoveDestination } from "@/lib/replayApi"; + +interface MoveEpisodesDialogProps { + datasets: DatasetItem[]; + repoId: string; + totalEpisodes: number; + currentEpisode: number | null; + open: boolean; + onOpenChange: (open: boolean) => void; + onMove: (episodeIndices: number[], destination: MoveDestination) => Promise; +} + +export default function MoveEpisodesDialog({ + datasets, + repoId, + totalEpisodes, + currentEpisode, + open, + onOpenChange, + onMove, +}: MoveEpisodesDialogProps) { + const [episodesText, setEpisodesText] = useState(""); + const [destination, setDestination] = useState(null); + const [moving, setMoving] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (open) { + setEpisodesText(currentEpisode === null ? "" : String(currentEpisode)); + setDestination(null); + setMoving(false); + setError(null); + } + }, [open, currentEpisode]); + + const candidates = useMemo(() => datasets.filter((d) => d.repo_id !== repoId), [datasets, repoId]); + const episodes = parseEpisodeList(episodesText, totalEpisodes); + const movesEverything = episodes !== null && episodes.length >= totalEpisodes; + const canMove = episodes !== null && !movesEverything && destination !== null && !moving; + const pickDestination = (next: MoveDestination) => { + setError(null); + setDestination(next); + }; + + const handleOpenChange = (nextOpen: boolean) => { + if (moving) return; + onOpenChange(nextOpen); + }; + + const handleMove = async () => { + if (!canMove || episodes === null || destination === null) return; + setMoving(true); + try { + await onMove(episodes, destination); + onOpenChange(false); + } catch (caughtError) { + setError(caughtError instanceof Error ? caughtError.message : "Could not move the episodes."); + } finally { + setMoving(false); + } + }; + + return ( + + + + Move episodes to another dataset + + The episodes leave {repoId} and go to a new or an existing local dataset. + The episodes left in {repoId} are renumbered. + + + +
+
+ + { + setError(null); + setEpisodesText(event.target.value); + }} + placeholder="3, 7, 10-12" + disabled={moving} + className="border-gray-700 bg-gray-800 text-white" + /> + {episodesText.trim() && episodes === null && ( +

+ Use episode numbers from 0 to {totalEpisodes - 1}, e.g. 3, 7, 10-12. +

+ )} + {movesEverything && ( +

At least one episode must stay in the dataset.

+ )} +
+ +
+ + pickDestination({ existingRepoId: item.repo_id })} + onCreateNew={(name) => pickDestination({ newName: name })} + > + + + {destination !== null && ( +

+ {"newName" in destination ? ( + <> + Saved locally as local/{destination.newName}. + + ) : ( + <> + Merged into {destination.existingRepoId} after its own episodes. It must + have the same fps, features and robot type. + + )} +

+ )} +
+ {error &&

{error}

} +
+ + + + + +
+
+ ); +} diff --git a/src/components/dataset/ReplayButton.test.tsx b/src/components/dataset/ReplayButton.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f03104ae2d21d9455f03aa5c1a86357ae51c82cb --- /dev/null +++ b/src/components/dataset/ReplayButton.test.tsx @@ -0,0 +1,148 @@ +// @vitest-environment jsdom +import React, { act } from "react"; +import { createRoot, Root } from "react-dom/client"; +import { afterEach, beforeEach, expect, test, vi } from "vitest"; +import ReplayButton from "./ReplayButton"; +import { RobotRecord } from "@/hooks/useRobots"; + +const backend = vi.hoisted(() => ({ + active: false, + startOk: true, + statusFails: false, + requests: [] as { path: string; body?: unknown }[], +})); +const robot = vi.hoisted(() => ({ current: null as RobotRecord | null })); +const toast = vi.hoisted(() => vi.fn()); +const api = vi.hoisted(() => ({ baseUrl: "http://localhost:8000", fetchWithHeaders: () => {} })); + +vi.mock("@/contexts/ApiContext", () => ({ useApi: () => api })); +vi.mock("@/hooks/useRobots", () => ({ useRobots: () => ({ selectedRecord: robot.current }) })); +vi.mock("@/hooks/use-toast", () => ({ useToast: () => ({ toast }) })); +vi.mock("@/lib/apiClient", () => ({ + apiRequest: async (_baseUrl: string, _fetcher: unknown, path: string, opts?: { body?: unknown }) => { + backend.requests.push({ path, body: opts?.body }); + if (path === "/replay-status") { + if (backend.statusFails) throw new Error("network"); + return { replay_active: backend.active, error: null }; + } + if (path === "/start-replay") { + backend.active = backend.startOk; + return { success: backend.startOk, message: "A replay is already running" }; + } + return { success: true, message: "Replay stopped" }; + }, +})); + +const calibrated: RobotRecord = { + name: "arm", + leader_port: "/dev/leader", + follower_port: "/dev/follower", + leader_config: "leader.json", + follower_config: "follower.json", + cameras: [], + is_clean: true, +}; + +let host: HTMLDivElement; +let root: Root; +const onStart = vi.fn(); +const onEnd = vi.fn(); + +const button = (label: string) => + [...host.querySelectorAll("button")].find((b) => b.textContent?.includes(label)); + +async function mount() { + await act(async () => { + root.render(); + }); +} + +async function poll() { + await act(async () => { + await vi.advanceTimersByTimeAsync(500); + }); +} + +beforeEach(() => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + vi.useFakeTimers(); + Object.assign(backend, { active: false, startOk: true, statusFails: false, requests: [] }); + robot.current = calibrated; + toast.mockClear(); + onStart.mockClear(); + onEnd.mockClear(); + host = document.createElement("div"); + root = createRoot(host); +}); + +afterEach(() => { + act(() => root.unmount()); + vi.useRealTimers(); +}); + +test("hidden until a calibrated robot is selected", async () => { + robot.current = null; + await mount(); + expect(host.querySelector("button")).toBeNull(); +}); + +test("a replay already running stays stoppable without a selected robot", async () => { + robot.current = null; + backend.active = true; + await mount(); + expect(button("Stop robot")).toBeDefined(); +}); + +test("starting replays this episode and runs the footage alongside", async () => { + await mount(); + await act(async () => button("Play on robot")!.click()); + expect(backend.requests.at(-1)).toMatchObject({ + path: "/start-replay", + body: { dataset_repo_id: "local/ds", episode_index: 2, follower_port: "/dev/follower" }, + }); + expect(onStart).toHaveBeenCalledOnce(); + expect(button("Stop robot")).toBeDefined(); +}); + +test("Stop stays until the status shows the arm released", async () => { + backend.active = true; + await mount(); + await act(async () => button("Stop robot")!.click()); + await poll(); + expect(button("Stop robot")).toBeDefined(); + + backend.active = false; + await poll(); + expect(button("Play on robot")).toBeDefined(); +}); + +test("a failed status poll keeps the Stop button", async () => { + backend.active = true; + await mount(); + backend.statusFails = true; + await poll(); + expect(button("Stop robot")).toBeDefined(); +}); + +test("a refused start reports it and offers Play again", async () => { + backend.startOk = false; + await mount(); + await act(async () => button("Play on robot")!.click()); + expect(onStart).not.toHaveBeenCalled(); + expect(toast).toHaveBeenCalledWith( + expect.objectContaining({ description: "A replay is already running" }), + ); + expect(button("Play on robot")).toBeDefined(); +}); + +test("the footage is told to stop once the replay ends or is stopped", async () => { + await mount(); + await act(async () => button("Play on robot")!.click()); + await poll(); + expect(onEnd).not.toHaveBeenCalled(); + + await act(async () => button("Stop robot")!.click()); + backend.active = false; + await poll(); + expect(onEnd).toHaveBeenCalledOnce(); +}); diff --git a/src/components/dataset/ReplayButton.tsx b/src/components/dataset/ReplayButton.tsx new file mode 100644 index 0000000000000000000000000000000000000000..86fffbbb7177598b50eedc804a833eb245e65b7a --- /dev/null +++ b/src/components/dataset/ReplayButton.tsx @@ -0,0 +1,150 @@ +import React, { useEffect, useRef, useState } from "react"; +import { Bot, Loader2, Square } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { useApi } from "@/contexts/ApiContext"; +import { useToast } from "@/hooks/use-toast"; +import { useRobots } from "@/hooks/useRobots"; +import { apiRequest } from "@/lib/apiClient"; + +interface ReplayResponse { + success: boolean; + message: string; +} + +interface ReplayStatus { + replay_active: boolean; + error: string | null; +} + +interface ReplayButtonProps { + repoId: string; + episodeIndex: number; + /** Called once the arm starts moving, so the footage can run alongside it. */ + onStart: () => void; + /** Called once the arm is released, whether the replay ended, failed or was stopped. */ + onEnd: () => void; +} + +/** + * Plays the episode's recorded actions on the selected follower arm. Only + * shown when that arm is ready to move. + */ +const ReplayButton: React.FC = ({ repoId, episodeIndex, onStart, onEnd }) => { + const { baseUrl, fetchWithHeaders } = useApi(); + const { selectedRecord: robot } = useRobots(); + const { toast } = useToast(); + const [phase, setPhase] = useState<"idle" | "starting" | "playing">("idle"); + // Paging to another episode while the arm connects must not drag the + // footage back to the one being replayed. + const episodeRef = useRef(episodeIndex); + episodeRef.current = episodeIndex; + + // A replay keeps running when this button unmounts (another dataset, another + // page), so pick it back up to keep it stoppable. + useEffect(() => { + apiRequest(baseUrl, fetchWithHeaders, "/replay-status") + .then((status) => { + if (status.replay_active) setPhase("playing"); + }) + .catch(() => undefined); + }, [baseUrl, fetchWithHeaders]); + + // Poll only while the arm moves, to notice when the episode ends or fails. + useEffect(() => { + if (phase !== "playing") return; + const timer = setInterval(async () => { + try { + const status = await apiRequest(baseUrl, fetchWithHeaders, "/replay-status"); + if (status.replay_active) return; + setPhase("idle"); + onEnd(); + if (status.error) { + toast({ title: "Replay failed", description: status.error, variant: "destructive" }); + } + } catch { + // Keep the Stop button: the next poll retries. + } + }, 500); + return () => clearInterval(timer); + }, [phase, baseUrl, fetchWithHeaders, toast, onEnd]); + + // Back to Play only once the status poll sees the arm released. + const stop = async () => { + try { + const res = await apiRequest(baseUrl, fetchWithHeaders, "/stop-replay", { + method: "POST", + action: "Stop", + }); + if (!res.success) throw new Error(res.message); + } catch (e) { + toast({ + title: "Could not stop the robot", + description: e instanceof Error ? e.message : String(e), + variant: "destructive", + }); + } + }; + + // A running replay stays stoppable even once the robot is no longer selected. + if (phase === "playing") { + return ( + + ); + } + + if (!robot?.is_clean) return null; + + const start = async () => { + setPhase("starting"); + try { + const res = await apiRequest(baseUrl, fetchWithHeaders, "/start-replay", { + method: "POST", + body: { + follower_port: robot.follower_port, + follower_config: robot.follower_config, + dataset_repo_id: repoId, + episode_index: episodeIndex, + }, + action: "Replay", + }); + if (!res.success) throw new Error(res.message); + setPhase("playing"); + if (episodeRef.current === episodeIndex) onStart(); + } catch (e) { + setPhase("idle"); + toast({ + title: "Could not replay this episode", + description: e instanceof Error ? e.message : String(e), + variant: "destructive", + }); + } + }; + + return ( + + ); +}; + +export default ReplayButton; diff --git a/src/components/jobs/CheckpointDropdown.tsx b/src/components/jobs/CheckpointDropdown.tsx new file mode 100644 index 0000000000000000000000000000000000000000..36597a7802c353a2292ce9b983036a62f881050b --- /dev/null +++ b/src/components/jobs/CheckpointDropdown.tsx @@ -0,0 +1,58 @@ +import React from "react"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { JobCheckpoint } from "@/lib/checkpointsApi"; + +interface Props { + checkpoints: JobCheckpoint[]; + selectedStep: number | null; + onChange: (step: number) => void; + disabled?: boolean; + placeholder?: string; +} + +export const CheckpointDropdown: React.FC = ({ + checkpoints, + selectedStep, + onChange, + disabled, + placeholder = "Select checkpoint", +}) => { + // step 0 is the sentinel for an imported single-model checkpoint (lerobot + // never saves at step 0), so it has no meaningful step number — show + // "latest" instead. Real training checkpoints keep their step label. + const labelFor = (step: number) => (step === 0 ? "latest" : `step ${step}`); + const value = selectedStep != null ? String(selectedStep) : undefined; + return ( + + ); +}; + +export default CheckpointDropdown; diff --git a/src/components/jobs/HubJobCard.tsx b/src/components/jobs/HubJobCard.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6f2a34d028e936339793803d38b5ec8b3796902b --- /dev/null +++ b/src/components/jobs/HubJobCard.tsx @@ -0,0 +1,106 @@ +import React from "react"; +import { Card, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { HubJob } from "@/lib/jobsApi"; +import { + ExternalLink, + AlertTriangle, + CheckCircle2, + Loader2, + XCircle, + Clock, + HelpCircle, +} from "lucide-react"; + +interface Props { + job: HubJob; +} + +function relativeTime(iso: string | null): string { + if (!iso) return "—"; + const t = Date.parse(iso); + if (Number.isNaN(t)) return "—"; + const diff = Math.max(0, (Date.now() - t) / 1000); + if (diff < 60) return `${Math.floor(diff)}s ago`; + if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; + if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; + return `${Math.floor(diff / 86400)}d ago`; +} + +interface StagePresentation { + label: string; + color: string; + Icon: React.ComponentType<{ className?: string }>; + spin?: boolean; +} + +const stagePresentation: Record = { + RUNNING: { label: "Running", color: "text-green-400", Icon: Loader2, spin: true }, + QUEUED: { label: "Queued", color: "text-amber-400", Icon: Clock }, + SCHEDULING: { label: "Scheduling", color: "text-amber-400", Icon: Clock }, + COMPLETED: { label: "Done", color: "text-slate-400", Icon: CheckCircle2 }, + FAILED: { label: "Failed", color: "text-red-400", Icon: XCircle }, + // HF API uses "CANCELED" (single L); accept both spellings. + CANCELED: { label: "Cancelled", color: "text-amber-400", Icon: AlertTriangle }, + CANCELLED: { label: "Cancelled", color: "text-amber-400", Icon: AlertTriangle }, +}; + +const HubJobCard: React.FC = ({ job }) => { + const stage = job.status?.stage?.toUpperCase() ?? ""; + const present: StagePresentation = stagePresentation[stage] ?? { + label: stage || "Unknown", + color: "text-slate-400", + Icon: HelpCircle, + }; + const Icon = present.Icon; + const title = + job.docker_image ?? job.space_id ?? `Job ${job.id.slice(0, 12)}…`; + + return ( + window.open(job.url, "_blank", "noopener,noreferrer")} + className="bg-slate-800/50 border-slate-700 rounded-xl cursor-pointer hover:border-slate-500 transition-colors" + > + +
+
+ + {present.label} +
+ +
+
+
+ {title} +
+
+ {job.flavor ?? "—"} · {relativeTime(job.created_at)} + {job.owner ? ` · ${job.owner}` : ""} +
+
+ {job.status?.message ? ( +
+ {job.status.message} +
+ ) : null} +
+
+ ); +}; + +export default HubJobCard; diff --git a/src/components/jobs/HubModelCard.tsx b/src/components/jobs/HubModelCard.tsx new file mode 100644 index 0000000000000000000000000000000000000000..599a54515fcb95c5ac1612d21a9843d5f518daec --- /dev/null +++ b/src/components/jobs/HubModelCard.tsx @@ -0,0 +1,75 @@ +import React from "react"; +import { Card, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { HubModel } from "@/lib/jobsApi"; +import { ExternalLink, Lock, Upload } from "lucide-react"; + +interface Props { + model: HubModel; +} + +function relativeTime(iso: string | null): string { + if (!iso) return "—"; + const t = Date.parse(iso); + if (Number.isNaN(t)) return "—"; + const diff = Math.max(0, (Date.now() - t) / 1000); + if (diff < 60) return `${Math.floor(diff)}s ago`; + if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; + if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; + return `${Math.floor(diff / 86400)}d ago`; +} + +const HubModelCard: React.FC = ({ model }) => { + const url = `https://huggingface.co/${model.repo_id}`; + const shortName = model.repo_id.includes("/") + ? model.repo_id.split("/").slice(1).join("/") + : model.repo_id; + + return ( + window.open(url, "_blank", "noopener,noreferrer")} + className="bg-slate-800/50 border-slate-700 rounded-xl cursor-pointer hover:border-slate-500 transition-colors" + > + +
+
+ + Uploaded +
+ +
+
+
+ {model.private ? ( + + ) : null} + {shortName} +
+
+ {model.repo_id} · updated {relativeTime(model.last_modified)} +
+
+
+
+ ); +}; + +export default HubModelCard; diff --git a/src/components/jobs/ImportModelModal.tsx b/src/components/jobs/ImportModelModal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b8b820d0e3f67c6f64bd3c31b4d5d2a7e3507cf2 --- /dev/null +++ b/src/components/jobs/ImportModelModal.tsx @@ -0,0 +1,121 @@ +import React, { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { AlertTriangle, Download, Loader2 } from "lucide-react"; +import { useApi } from "@/contexts/ApiContext"; +import { importModel } from "@/lib/jobsApi"; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + onImported: () => void; +} + +const ImportModelModal: React.FC = ({ open, onOpenChange, onImported }) => { + const { baseUrl, fetchWithHeaders } = useApi(); + const [source, setSource] = useState(""); + const [name, setName] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + const handleSubmit = async () => { + const src = source.trim(); + if (!src) return; + setSubmitting(true); + setError(null); + try { + await importModel(baseUrl, fetchWithHeaders, src, name.trim() || undefined); + setSource(""); + setName(""); + onOpenChange(false); + onImported(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setSubmitting(false); + } + }; + + return ( + + + + + Import a model + + + Point at a local directory or a Hugging Face repo. It appears as a + job you can run inference on. + + + +
+
+ + setSource(e.target.value)} + placeholder="/path/to/pretrained_model or user/my-policy" + className="bg-gray-800 border-gray-700 text-white" + /> +
+
+ + setName(e.target.value)} + placeholder="My imported policy" + className="bg-gray-800 border-gray-700 text-white" + /> +
+ + {error ? ( + + + {error} + + ) : null} + +
+ + +
+
+
+
+ ); +}; + +export default ImportModelModal; diff --git a/src/components/jobs/JobCard.tsx b/src/components/jobs/JobCard.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2a0af722a9c3a554560bc386565e1ea314a36afc --- /dev/null +++ b/src/components/jobs/JobCard.tsx @@ -0,0 +1,221 @@ +import React, { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { Card, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { JobRecord } from "@/lib/jobsApi"; +import { + Square, + X, + AlertTriangle, + CheckCircle2, + Loader2, + XCircle, + ExternalLink, + Play, +} from "lucide-react"; +import { useApi } from "@/contexts/ApiContext"; +import { + JobCheckpoint, + listJobCheckpoints, +} from "@/lib/checkpointsApi"; +import CheckpointDropdown from "@/components/jobs/CheckpointDropdown"; + +interface Props { + job: JobRecord; + onStop: (id: string) => void; + onDelete: (id: string) => void; + onPlay: (job: JobRecord, step: number) => void; +} + +function relativeTime(epochSec: number): string { + const diff = Math.max(0, Date.now() / 1000 - epochSec); + if (diff < 60) return `${Math.floor(diff)}s ago`; + if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; + if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; + return `${Math.floor(diff / 86400)}d ago`; +} + +const statePresentation: Record< + JobRecord["state"], + { label: string; color: string; Icon: React.ComponentType<{ className?: string }> } +> = { + running: { label: "Running", color: "text-green-400", Icon: Loader2 }, + done: { label: "Done", color: "text-slate-400", Icon: CheckCircle2 }, + failed: { label: "Failed", color: "text-red-400", Icon: XCircle }, + interrupted: { label: "Interrupted", color: "text-amber-400", Icon: AlertTriangle }, +}; + +const JobCard: React.FC = ({ job, onStop, onDelete, onPlay }) => { + const navigate = useNavigate(); + const { baseUrl, fetchWithHeaders } = useApi(); + const present = statePresentation[job.state]; + const Icon = present.Icon; + const isRunning = job.state === "running"; + const isImported = job.runner === "imported"; + const importedSource = job.hf_repo_id || job.output_dir; + const stateLabel = isImported ? "Imported" : present.label; + const isStarting = isRunning && job.metrics.total_steps === 0; + const progressPct = + job.metrics.total_steps > 0 + ? Math.min(100, (job.metrics.current_step / job.metrics.total_steps) * 100) + : 0; + + const subtitle = isImported + ? importedSource + : isStarting + ? "starting…" + : isRunning + ? `started ${relativeTime(job.started_at)}` + : job.ended_at != null + ? `ended ${relativeTime(job.ended_at)}` + : present.label.toLowerCase(); + + const [checkpoints, setCheckpoints] = useState([]); + const [selectedStep, setSelectedStep] = useState(null); + + useEffect(() => { + if (job.checkpoint_count <= 0) { + setCheckpoints([]); + setSelectedStep(null); + return; + } + let cancelled = false; + listJobCheckpoints(baseUrl, fetchWithHeaders, job.id) + .then((cks) => { + if (cancelled) return; + setCheckpoints(cks); + if (cks.length > 0) { + const latest = cks[cks.length - 1].step; + setSelectedStep((prev) => + prev != null && cks.some((c) => c.step === prev) ? prev : latest, + ); + } else { + setSelectedStep(null); + } + }) + .catch(() => { + if (!cancelled) { + setCheckpoints([]); + setSelectedStep(null); + } + }); + return () => { + cancelled = true; + }; + }, [baseUrl, fetchWithHeaders, job.id, job.checkpoint_count]); + + const handleAction = (e: React.MouseEvent) => { + e.stopPropagation(); + if (isRunning) { + if (window.confirm("Stop this run?")) onStop(job.id); + } else if (isImported) { + if (window.confirm("Remove this imported model? The source files are left untouched.")) + onDelete(job.id); + } else if (window.confirm("Delete this run? This wipes the output directory.")) { + onDelete(job.id); + } + }; + + const handlePlay = (e: React.MouseEvent) => { + e.stopPropagation(); + if (selectedStep == null) return; + onPlay(job, selectedStep); + }; + + const showProgressBar = isRunning; + const showInferenceRow = checkpoints.length > 0 && selectedStep != null; + + return ( + { + if (!isImported) navigate(`/training/${job.id}`); + }} + className={`bg-slate-800/50 border-slate-700 rounded-xl transition-colors ${ + isImported ? "" : "cursor-pointer hover:border-slate-500" + }`} + > + +
+
+ + {stateLabel} +
+ {job.runner === "hf_cloud" && job.hf_job_url ? ( + + ) : ( + + )} +
+
+
+ {job.name} +
+ {/* Imported subtitles are file paths — truncate the *start* (rtl + flips the ellipsis to the left) so the more useful tail stays + visible. The leading LRM keeps the path's first "/" from being + bidi-reordered to the wrong end. */} +
+ {isImported ? "\u200e" + subtitle : subtitle} +
+
+ {showProgressBar ? ( +
+
+
+ {isStarting ? "Training starting…" : `${progressPct.toFixed(1)}%`} +
+
+ ) : null} + {showInferenceRow ? ( +
+ + +
+ ) : null} + + + ); +}; + +export default JobCard; diff --git a/src/components/jobs/JobsSection.tsx b/src/components/jobs/JobsSection.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0424464758747f54dc516b9f51d209a70f4ba0ce --- /dev/null +++ b/src/components/jobs/JobsSection.tsx @@ -0,0 +1,435 @@ +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { useApi } from "@/contexts/ApiContext"; +import { useToast } from "@/hooks/use-toast"; +import { useJobsChangedSignal } from "@/hooks/useJobsChangedSignal"; +import { + HubJob, + HubModel, + JobProgressSnapshot, + JobRecord, + deleteJob, + listHubJobs, + listJobs, + stopJob, +} from "@/lib/jobsApi"; +import JobCard from "./JobCard"; +import HubJobCard from "./HubJobCard"; +import HubModelCard from "./HubModelCard"; +import InferenceModal from "@/components/landing/InferenceModal"; +import ImportModelModal from "./ImportModelModal"; +import { useRobots } from "@/hooks/useRobots"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { ChevronRight, Download, RefreshCw, Search } from "lucide-react"; + +const LIMIT = 10; + +// Hub stages still doing work. Anything outside this set (COMPLETED, FAILED, +// CANCELED, …) gets demoted to UNTRACKED. +const HUB_ACTIVE_STAGES = new Set(["RUNNING", "QUEUED", "SCHEDULING"]); + +const isJobActive = (j: JobRecord) => + j.state === "running" || j.checkpoint_count > 0; + +const isHubJobActive = (h: HubJob) => + HUB_ACTIVE_STAGES.has((h.status?.stage ?? "").toUpperCase()); + +const JobsSection: React.FC = () => { + const { baseUrl, fetchWithHeaders } = useApi(); + const { toast } = useToast(); + + const [jobs, setJobs] = useState([]); + const [hubJobs, setHubJobs] = useState([]); + const [hubModels, setHubModels] = useState([]); + const [hubAuthenticated, setHubAuthenticated] = useState(false); + const [error, setError] = useState(null); + const [search, setSearch] = useState(""); + + const { selectedRecord } = useRobots(); + const [inferenceModalOpen, setInferenceModalOpen] = useState(false); + const [importModalOpen, setImportModalOpen] = useState(false); + const [inferenceJob, setInferenceJob] = useState(null); + const [inferenceStep, setInferenceStep] = useState(null); + + const refresh = useCallback(async () => { + try { + const [next, hub] = await Promise.all([ + listJobs(baseUrl, fetchWithHeaders, LIMIT), + listHubJobs(baseUrl, fetchWithHeaders), + ]); + setJobs(next); + setHubJobs(hub.jobs); + setHubModels(hub.models); + setHubAuthenticated(hub.authenticated); + setError(null); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } + }, [baseUrl, fetchWithHeaders]); + + // Initial fetch on mount + refetch when the tab regains focus. Backend + // pushes a `jobs_changed` WS event on every registry mutation, which + // covers any change originating on this machine. The focus refresh + // catches changes originating elsewhere (e.g. a job submitted from + // another tab or the HF dashboard) without burning the rate limit. + useEffect(() => { + refresh(); + const onVisible = () => { + if (document.visibilityState === "visible") refresh(); + }; + document.addEventListener("visibilitychange", onVisible); + window.addEventListener("focus", refresh); + return () => { + document.removeEventListener("visibilitychange", onVisible); + window.removeEventListener("focus", refresh); + }; + }, [refresh]); + + const applyProgress = useCallback((snapshots: JobProgressSnapshot[]) => { + if (snapshots.length === 0) return; + setJobs((prev) => { + if (prev.length === 0) return prev; + const byId = new Map(snapshots.map((s) => [s.id, s])); + let mutated = false; + const next = prev.map((j) => { + const s = byId.get(j.id); + if (!s) return j; + mutated = true; + return { + ...j, + state: s.state, + metrics: s.metrics, + wandb_run_url: s.wandb_run_url, + checkpoint_count: s.checkpoint_count, + }; + }); + return mutated ? next : prev; + }); + }, []); + + useJobsChangedSignal(refresh, applyProgress); + + const handleStop = async (id: string) => { + try { + await stopJob(baseUrl, fetchWithHeaders, id); + toast({ title: "Job stopping" }); + refresh(); + } catch (e) { + toast({ + title: "Stop failed", + description: e instanceof Error ? e.message : String(e), + variant: "destructive", + }); + } + }; + + const handlePlay = (job: JobRecord, step: number) => { + setInferenceJob(job); + setInferenceStep(step); + setInferenceModalOpen(true); + }; + + const handleDelete = async (id: string) => { + try { + await deleteJob(baseUrl, fetchWithHeaders, id); + toast({ title: "Job removed" }); + refresh(); + } catch (e) { + toast({ + title: "Delete failed", + description: e instanceof Error ? e.message : String(e), + variant: "destructive", + }); + } + }; + + const query = search.trim().toLowerCase(); + const matchesQuery = useCallback( + (text: string | null | undefined) => + !query || (text ?? "").toLowerCase().includes(query), + [query], + ); + + const filteredJobs = useMemo( + () => jobs.filter((j) => matchesQuery(j.name)), + [jobs, matchesQuery], + ); + const filteredHubJobs = useMemo( + () => + hubJobs.filter((h) => + matchesQuery(h.docker_image ?? h.space_id ?? h.id), + ), + [hubJobs, matchesQuery], + ); + const filteredHubModels = useMemo( + () => hubModels.filter((m) => matchesQuery(m.repo_id)), + [hubModels, matchesQuery], + ); + + const localJobs = useMemo( + () => filteredJobs.filter((j) => j.runner === "local"), + [filteredJobs], + ); + const trackedCloudJobs = useMemo( + () => filteredJobs.filter((j) => j.runner === "hf_cloud"), + [filteredJobs], + ); + const importedJobs = useMemo( + () => filteredJobs.filter((j) => j.runner === "imported"), + [filteredJobs], + ); + // Hub jobs already mirrored by a local JobRecord get their richer card via + // trackedCloudJobs; everything else from the hub gets a plain HubJobCard. + const trackedHfJobIds = useMemo( + () => + new Set( + trackedCloudJobs + .map((j) => j.hf_job_id) + .filter((id): id is string => !!id), + ), + [trackedCloudJobs], + ); + const untrackedHubJobs = useMemo( + () => filteredHubJobs.filter((h) => !trackedHfJobIds.has(h.id)), + [filteredHubJobs, trackedHfJobIds], + ); + // Hide model repos that map 1-to-1 to a tracked cloud job (those already + // appear via JobCard); the remainder are past trainings the registry no + // longer remembers. + const trackedRepoIds = useMemo( + () => + new Set( + trackedCloudJobs + .map((j) => j.hf_repo_id) + .filter((id): id is string => !!id), + ), + [trackedCloudJobs], + ); + const untrackedHubModels = useMemo( + () => filteredHubModels.filter((m) => !trackedRepoIds.has(m.repo_id)), + [filteredHubModels, trackedRepoIds], + ); + + // Active = running or has runnable checkpoints. Everything else collapses + // under UNTRACKED so the eye lands on what's still relevant. + const localActive = useMemo(() => localJobs.filter(isJobActive), [localJobs]); + const localUntracked = useMemo( + () => localJobs.filter((j) => !isJobActive(j)), + [localJobs], + ); + const trackedCloudActive = useMemo( + () => trackedCloudJobs.filter(isJobActive), + [trackedCloudJobs], + ); + const trackedCloudUntracked = useMemo( + () => trackedCloudJobs.filter((j) => !isJobActive(j)), + [trackedCloudJobs], + ); + const untrackedHubActive = useMemo( + () => untrackedHubJobs.filter(isHubJobActive), + [untrackedHubJobs], + ); + const untrackedHubInactive = useMemo( + () => untrackedHubJobs.filter((h) => !isHubJobActive(h)), + [untrackedHubJobs], + ); + + const untrackedCount = + localUntracked.length + + trackedCloudUntracked.length + + untrackedHubInactive.length; + + return ( +
+
+

Jobs

+
+
+ + setSearch(e.target.value)} + placeholder="Search jobs" + className="h-8 w-48 sm:w-60 pl-8 bg-slate-800/50 border-slate-700 text-sm text-white placeholder:text-slate-500" + aria-label="Search jobs" + /> +
+ + +
+
+ + {error ?

Couldn't load jobs: {error}

: null} + + + + + Local jobs ({localActive.length}) + + + {localActive.length === 0 ? ( +

+ {query + ? "No local jobs match your search." + : "No active local jobs. Start one from the Training page."} +

+ ) : ( +
+ {localActive.map((job) => ( + + ))} +
+ )} +
+
+ + {importedJobs.length > 0 ? ( + <> +
+ + + + Imported models ({importedJobs.length}) + + +
+ {importedJobs.map((job) => ( + + ))} +
+
+
+ + ) : null} + +
+ + + + + Online jobs ( + {trackedCloudActive.length + + untrackedHubActive.length + + untrackedHubModels.length} + ) + + + {!hubAuthenticated && trackedCloudJobs.length === 0 ? ( +

+ Sign in with Hugging Face to see your cloud jobs. +

+ ) : trackedCloudActive.length === 0 && + untrackedHubActive.length === 0 && + untrackedHubModels.length === 0 ? ( +

+ {query ? "No online jobs match your search." : "No active cloud jobs."} +

+ ) : ( +
+ {trackedCloudActive.map((job) => ( + + ))} + {untrackedHubActive.map((job) => ( + + ))} + {untrackedHubModels.map((model) => ( + + ))} +
+ )} +
+
+ + {untrackedCount > 0 ? ( + + + + Untracked ({untrackedCount}) + + +
+ {localUntracked.map((job) => ( + + ))} + {trackedCloudUntracked.map((job) => ( + + ))} + {untrackedHubInactive.map((job) => ( + + ))} +
+
+
+ ) : null} + + {inferenceJob ? ( + + ) : null} + + +
+ ); +}; + +export default JobsSection; diff --git a/src/components/landing/CameraBindings.tsx b/src/components/landing/CameraBindings.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d9b9723aa3f73bac268f9cdceee8e2d235c1049f --- /dev/null +++ b/src/components/landing/CameraBindings.tsx @@ -0,0 +1,92 @@ +import React from "react"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { AvailableCamera } from "@/hooks/useAvailableCameras"; +import { ImageFeatures } from "@/lib/checkpointsApi"; +import BrowserCameraPreview from "@/components/control/BrowserCameraPreview"; +import CameraRotationSelect from "@/components/control/CameraRotationSelect"; +import { CameraRotation, rotatedDimensions } from "@/lib/cameraConfig"; + +interface CameraBindingsProps { + imageFeatures: ImageFeatures; + bindings: Record; + onBind: (name: string, index: number) => void; + rotationFor: (name: string) => CameraRotation; + onRotate: (name: string, rotation: CameraRotation) => void; + availableCameras: AvailableCamera[]; + /** Drop the previews so the backend can open the same devices. */ + paused: boolean; +} + +/** One row per expected camera: its resolution, a physical camera picker and a preview. */ +const CameraBindings: React.FC = ({ + imageFeatures, + bindings, + onBind, + rotationFor, + onRotate, + availableCameras, + paused, +}) => ( + <> + {Object.entries(imageFeatures).map(([name, dims]) => { + const value = bindings[name]; + const bound = + value != null ? availableCameras.find((c) => c.index === value) : undefined; + const rotation = rotationFor(name); + const capture = rotatedDimensions(dims.width, dims.height, rotation); + return ( +
+
+ +

+ {dims.width}×{dims.height} +

+
+ + onRotate(name, angle)} + /> + +
+ ); + })} + +); + +export default CameraBindings; diff --git a/src/components/landing/DatasetPicker.tsx b/src/components/landing/DatasetPicker.tsx new file mode 100644 index 0000000000000000000000000000000000000000..14822e830b5f6f69afcf53cb9cc93c5524e6741b --- /dev/null +++ b/src/components/landing/DatasetPicker.tsx @@ -0,0 +1,181 @@ +import React, { useState } from "react"; +import { Plus, ExternalLink } from "lucide-react"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { DatasetItem } from "@/lib/replayApi"; + +interface DatasetPickerProps { + datasets: DatasetItem[]; + loading: boolean; + onPickExisting: (item: DatasetItem) => void; + onCreateNew?: (name: string) => void; + onOpenCustom?: (repoId: string) => void; + children: React.ReactNode; +} + +const REPO_ID_RE = /^[\w.\-]+\/[\w.\-]+$/; +const NAME_RE = /^[A-Za-z0-9._-]+$/; + +const DatasetPicker: React.FC = ({ + datasets, + loading, + onPickExisting, + onCreateNew, + onOpenCustom, + children, +}) => { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + + const trimmed = query.trim(); + const matchesExisting = datasets.some( + (d) => d.repo_id.toLowerCase() === trimmed.toLowerCase(), + ); + const isRepoId = REPO_ID_RE.test(trimmed); + const isName = NAME_RE.test(trimmed) && !trimmed.includes("/"); + const canCreate = !!onCreateNew && trimmed.length > 0 && isName && !matchesExisting; + const canOpenCustom = !!onOpenCustom && isRepoId && !matchesExisting; + + const createDisabled = matchesExisting || (trimmed !== "" && !canCreate); + const createLabel = matchesExisting + ? "Already exists" + : trimmed === "" + ? "Create new dataset…" + : canCreate + ? `Create "${trimmed}"` + : 'Use a name without "/"'; + + const handleFooterCreate = () => { + if (createDisabled) return; + onCreateNew?.(trimmed); + reset(); + }; + + const localDatasets = datasets.filter((d) => d.source === "local" || d.source === "both"); + const hubDatasets = datasets.filter((d) => d.source === "hub"); + + const reset = () => { + setQuery(""); + setOpen(false); + }; + + const handlePick = (item: DatasetItem) => { + onPickExisting(item); + reset(); + }; + + const handleCreate = () => { + if (!canCreate) return; + onCreateNew?.(trimmed); + reset(); + }; + + const handleOpenCustom = () => { + if (!canOpenCustom) return; + onOpenCustom?.(trimmed); + reset(); + }; + + const renderItem = (d: DatasetItem) => ( + handlePick(d)} + className="text-white aria-selected:bg-gray-700" + > + {d.repo_id} + {d.source === "both" && ( + on Hub + )} + {d.private && ( + private + )} + + ); + + return ( + + {children} + + + setQuery(v.replace(/[^A-Za-z0-9._\-/]/g, "_"))} + onKeyDown={(e) => { + if (e.key !== "Enter") return; + if (canCreate) { + e.preventDefault(); + handleCreate(); + } else if (canOpenCustom) { + e.preventDefault(); + handleOpenCustom(); + } + }} + className="text-white" + /> + + {datasets.length === 0 && !canCreate && !canOpenCustom && ( + + {loading + ? "Loading datasets…" + : onCreateNew + ? "No datasets yet. Type a name to create one." + : "No datasets available."} + + )} + {localDatasets.length > 0 && ( + + {localDatasets.map(renderItem)} + + )} + {hubDatasets.length > 0 && ( + + {hubDatasets.map(renderItem)} + + )} + {canOpenCustom && ( + + + + Open "{trimmed}" in viewer + + + )} + + {onCreateNew && ( + + )} + + + + ); +}; + +export default DatasetPicker; diff --git a/src/components/landing/HfAuthBanner.tsx b/src/components/landing/HfAuthBanner.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a391ec1692dbc7045a394eaeaebb2d575aa50e61 --- /dev/null +++ b/src/components/landing/HfAuthBanner.tsx @@ -0,0 +1,107 @@ +import React, { useState } from "react"; +import { AlertCircle, ExternalLink, Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { useApi } from "@/contexts/ApiContext"; +import { useHfAuth } from "@/contexts/HfAuthContext"; + +const HfAuthBanner: React.FC = () => { + const { auth, refetch } = useHfAuth(); + const { baseUrl, fetchWithHeaders } = useApi(); + const [token, setToken] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + if (auth.status === "authenticated" || auth.status === "loading") { + return null; + } + + const handleSave = async () => { + const trimmed = token.trim(); + if (!trimmed) return; + setSubmitting(true); + setError(null); + try { + const r = await fetchWithHeaders(`${baseUrl}/hf-auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token: trimmed }), + }); + if (!r.ok) { + const body = await r.json().catch(() => ({})); + throw new Error(body.detail || `HTTP ${r.status}`); + } + setToken(""); + await refetch(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setSubmitting(false); + } + }; + + return ( +
+
+ +
+
+

+ Hugging Face access required for cloud training +

+

+ Create a token at{" "} + + huggingface.co/settings/tokens + + + {" "}with Write access (so trained + policies can upload to your account), then paste it below. +

+
+
{ + e.preventDefault(); + handleSave(); + }} + className="flex gap-2" + > + setToken(e.target.value)} + className="bg-slate-900 border-slate-600 text-white placeholder:text-slate-500" + disabled={submitting} + autoComplete="off" + /> + +
+ {error && ( +

{error}

+ )} +
+
+
+ ); +}; + +export default HfAuthBanner; diff --git a/src/components/landing/HfAuthChip.tsx b/src/components/landing/HfAuthChip.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c17b0ce6ac3a15fc929d602d5e1bdf8280d93410 --- /dev/null +++ b/src/components/landing/HfAuthChip.tsx @@ -0,0 +1,54 @@ +import React, { useState } from "react"; +import { Loader2 } from "lucide-react"; +import { useHfAuth } from "@/contexts/HfAuthContext"; +import HfAuthDialog from "./HfAuthDialog"; + +const HfAuthChip: React.FC = () => { + const { auth } = useHfAuth(); + const [dialogOpen, setDialogOpen] = useState(false); + + if (auth.status === "loading") { + return ( +
+ + Checking HF… +
+ ); + } + + if (auth.status === "authenticated") { + return ( +
+
+ ); + } + + // unauthenticated + return ( + <> + + + + ); +}; + +export default HfAuthChip; diff --git a/src/components/landing/HfAuthDialog.tsx b/src/components/landing/HfAuthDialog.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f75693ca2adeaf85aaba592107dc2daf540ee8a7 --- /dev/null +++ b/src/components/landing/HfAuthDialog.tsx @@ -0,0 +1,90 @@ +import React, { useState } from "react"; +import { Check, Copy, RefreshCw } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { useHfAuth } from "@/contexts/HfAuthContext"; + +interface HfAuthDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +const HfAuthDialog: React.FC = ({ open, onOpenChange }) => { + const { auth, refetch } = useHfAuth(); + const [copied, setCopied] = useState(false); + const [refetching, setRefetching] = useState(false); + + if (auth.status !== "unauthenticated") { + return null; + } + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(auth.loginCommand); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch (err) { + console.warn("Clipboard write failed:", err); + } + }; + + const handleRefetch = async () => { + setRefetching(true); + try { + await refetch(); + } finally { + setRefetching(false); + } + }; + + return ( + + + + + Hugging Face CLI not configured + + + Uploads, training, and replay-from-Hub require a logged-in HF CLI. + Run this in a terminal: + + +
+          {auth.loginCommand}
+          
+        
+ +
+
+ ); +}; + +export default HfAuthDialog; diff --git a/src/components/landing/InferenceModal.test.tsx b/src/components/landing/InferenceModal.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..313cbb2a2165ca8e3b4bc88ea22ba330582159f3 --- /dev/null +++ b/src/components/landing/InferenceModal.test.tsx @@ -0,0 +1,111 @@ +// @vitest-environment jsdom +import React, { act } from "react"; +import { createRoot, Root } from "react-dom/client"; +import { afterEach, beforeEach, expect, test, vi } from "vitest"; +import InferenceModal from "./InferenceModal"; +import { RobotRecord } from "@/hooks/useRobots"; +const mocks = vi.hoisted(() => ({ fetcher: vi.fn(), navigate: vi.fn(), toast: vi.fn() })); +const available = [ + { index: 1, name: "top", deviceId: "usb-top", available: true }, + { index: 2, name: "wrist", deviceId: "usb-wrist", available: true }, + { index: 3, name: "manual", deviceId: "usb-manual", available: true }, +]; +vi.mock("@/contexts/ApiContext", () => ({ useApi: () => ({ baseUrl: "http://test", fetchWithHeaders: mocks.fetcher }) })); +vi.mock("@/hooks/use-toast", () => ({ useToast: () => ({ toast: mocks.toast }) })); +vi.mock("react-router-dom", () => ({ useNavigate: () => mocks.navigate })); +vi.mock("@/hooks/useAvailableCameras", () => ({ useAvailableCameras: () => ({ cameras: available }) })); +vi.mock("@/hooks/useCameraStream", () => ({ useCameraStream: () => ({ videoRef: React.createRef(), hasError: false }) })); +// Use native selectors to test modal state changes without Radix popup event details. +vi.mock("@/components/ui/select", () => ({ + Select: ({ value, onValueChange, disabled, children }: { value: string; onValueChange: (value: string) => void; disabled: boolean; children: React.ReactNode }) => ( + + ), + SelectContent: ({ children }: { children: React.ReactNode }) => <>{children}, + SelectItem: ({ value, children }: { value: string; children: React.ReactNode }) => , + SelectTrigger: () => null, + SelectValue: () => null, +})); +vi.mock("@/components/ui/dialog", () => ({ + Dialog: ({ open, children }: { open: boolean; children: React.ReactNode }) => open ?
{children}
: null, + DialogContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogHeader: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogTitle: ({ children }: { children: React.ReactNode }) =>

{children}

, + DialogDescription: ({ children }: { children: React.ReactNode }) =>

{children}

, +})); +const robot: RobotRecord = { + name: "so101", leader_port: "leader", follower_port: "follower", leader_config: "leader.json", follower_config: "follower.json", is_clean: true, + cameras: [ + { id: "wrist", name: "wrist", type: "opencv", device_id: "usb-wrist", camera_index: 1, width: 640, height: 480, rotation: 90 }, + { id: "top", name: "top", type: "opencv", device_id: "usb-top", camera_index: 2, width: 640, height: 480, rotation: 180 }, + ], +}; +let root: Root; +let container: HTMLDivElement; +beforeEach(() => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + container = document.createElement("div"); root = createRoot(container); + mocks.fetcher.mockReset(); mocks.navigate.mockReset(); mocks.toast.mockReset(); + mocks.fetcher.mockImplementation(async (url: string) => new Response(JSON.stringify( + url.endsWith("/checkpoints") ? { checkpoints: [{ step: 10, source: "local", ref: "checkpoint" }] } + : url.endsWith("/policy-config") ? { policy_type: "act", requires_task: false, image_features: { wrist: { width: 480, height: 640 }, top: { width: 640, height: 480 } } } + : { message: "Started", log_path: "rollout.log" }, + ))); +}); +afterEach(() => { act(() => root.unmount()); vi.useRealTimers(); }); +const rotations = () => Array.from(container.querySelectorAll("select")).filter((select) => Array.from(select.options).some((option) => option.text.includes("clockwise"))); +const bindings = () => Array.from(container.querySelectorAll("select")).filter((select) => Array.from(select.options).some((option) => option.text.includes("#"))); +function change(select: HTMLSelectElement, value: string) { + act(() => { select.value = value; select.dispatchEvent(new Event("change", { bubbles: true })); }); +} +async function render(record = robot) { + await act(async () => root.render()); +} +async function start() { + vi.useFakeTimers(); + const button = Array.from(container.querySelectorAll("button")).find((item) => item.textContent?.includes("Start Inference"))!; + await act(async () => { button.click(); await vi.advanceTimersByTimeAsync(300); }); + const call = mocks.fetcher.mock.calls.find(([url]) => url.endsWith("/start-inference")); + return call ? JSON.parse(call[1].body) : null; +} + +test("camera reassignment carries physical rotation and sends native dimensions to inference", async () => { + await render(); + expect(bindings().map((select) => select.value)).toEqual(["2", "1"]); + expect(rotations().map((select) => select.value)).toEqual(["90", "180"]); + change(bindings()[0], "1"); change(bindings()[1], "2"); + expect(rotations().map((select) => select.value)).toEqual(["180", "90"]); + change(rotations()[0], "270"); + const request = await start(); + expect(request.cameras.wrist).toMatchObject({ camera_index: 1, rotation: 270, width: 640, height: 480 }); + expect(request.cameras.top).toMatchObject({ camera_index: 2, rotation: 90, width: 480, height: 640 }); + expect(mocks.navigate).toHaveBeenCalledWith("/inference"); +}); + +test("an unsaved camera permits a manual rotation with a training-data reminder", async () => { + await render(); change(bindings()[0], "3"); + expect(rotations()[0].value).toBe("0"); + change(rotations()[0], "90"); + expect(container.textContent).toContain("Use the same rotation as the training data"); + const request = await start(); + expect(request.cameras.wrist).toMatchObject({ camera_index: 3, rotation: 90, width: 640, height: 480 }); +}); + +test("the plugin package note shows only for a policy type LeRobot does not register", async () => { + await render(); + expect(container.textContent).not.toContain("plugin policy"); + act(() => root.unmount()); root = createRoot(container); + const builtin = mocks.fetcher.getMockImplementation()!; + mocks.fetcher.mockImplementation(async (url: string, init?: RequestInit) => url.endsWith("/policy-config") + ? new Response(JSON.stringify({ policy_type: "my_plugin", requires_task: true, image_features: {} })) + : builtin(url, init)); + await render(); + expect(container.textContent).toContain("This is a plugin policy"); +}); + +test("an invalid saved rotation blocks inference and gives a visible correction instruction", async () => { + await render({ ...robot, cameras: [{ ...robot.cameras[0], rotation: 45 as never }] }); + expect(container.textContent).toContain("Correct the camera settings in Calibration"); + const button = Array.from(container.querySelectorAll("button")).find((item) => item.textContent?.includes("Start Inference"))! as HTMLButtonElement; + expect(button.disabled).toBe(true); + expect(await start()).toBeNull(); +}); diff --git a/src/components/landing/InferenceModal.tsx b/src/components/landing/InferenceModal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d7b3ad031ded091db744696cdce818b64d6cf73a --- /dev/null +++ b/src/components/landing/InferenceModal.tsx @@ -0,0 +1,362 @@ +import React, { useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { NumberInput } from "@/components/ui/number-input"; +import { Label } from "@/components/ui/label"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { AlertTriangle, CheckCircle, Loader2, Play } from "lucide-react"; +import { RobotRecord } from "@/hooks/useRobots"; +import { useApi } from "@/contexts/ApiContext"; +import { useToast } from "@/hooks/use-toast"; +import { useNavigate } from "react-router-dom"; +import { + JobCheckpoint, + PolicyConfigSummary, + getCheckpointPolicyConfig, + listJobCheckpoints, +} from "@/lib/checkpointsApi"; +import { startInference } from "@/lib/inferenceApi"; +import CheckpointDropdown from "@/components/jobs/CheckpointDropdown"; +import { useCameraBindings } from "@/hooks/useCameraBindings"; +import { cameraConfigurationError } from "@/lib/cameraConfig"; +import CameraBindings from "./CameraBindings"; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + robot: RobotRecord | null; + jobId: string; + initialStep: number | null; +} + +const DEFAULT_FPS = 30; +// Policy types LeRobot (v0.6.1) registers itself; any other type comes from a plugin package. +const BUILTIN_POLICY_TYPES = new Set([ + "act", + "diffusion", + "eo1", + "evo1", + "fastwam", + "gaussian_actor", + "groot", + "lingbot_va", + "molmoact2", + "multi_task_dit", + "pi0", + "pi05", + "pi0_fast", + "smolvla", + "tdmpc", + "vla_jepa", + "vqbet", + "wall_x", + "xvla", +]); + +const InferenceModal: React.FC = ({ + open, + onOpenChange, + robot, + jobId, + initialStep, +}) => { + const { baseUrl, fetchWithHeaders } = useApi(); + const { toast } = useToast(); + const navigate = useNavigate(); + + const [checkpoints, setCheckpoints] = useState([]); + const [selectedStep, setSelectedStep] = useState(initialStep); + const [task, setTask] = useState(""); + const [durationS, setDurationS] = useState(60); + const [submitting, setSubmitting] = useState(false); + + const [policyConfig, setPolicyConfig] = useState(null); + const [policyConfigLoading, setPolicyConfigLoading] = useState(false); + const [policyConfigError, setPolicyConfigError] = useState(null); + + const cameraBindings = useCameraBindings(policyConfig?.image_features ?? null, robot, open); + + // Load checkpoints when modal opens. + useEffect(() => { + if (!open) return; + let cancelled = false; + listJobCheckpoints(baseUrl, fetchWithHeaders, jobId) + .then((cks) => { + if (cancelled) return; + setCheckpoints(cks); + if (cks.length > 0) { + const latest = cks[cks.length - 1].step; + setSelectedStep((prev) => (prev != null ? prev : latest)); + } + }) + .catch(() => { + if (cancelled) return; + setCheckpoints([]); + }); + return () => { + cancelled = true; + }; + }, [open, baseUrl, fetchWithHeaders, jobId]); + + + // Load policy config when step changes. + useEffect(() => { + if (!open || selectedStep == null) { + setPolicyConfig(null); + setPolicyConfigError(null); + return; + } + let cancelled = false; + setPolicyConfigLoading(true); + setPolicyConfigError(null); + getCheckpointPolicyConfig(baseUrl, fetchWithHeaders, jobId, selectedStep) + .then((cfg) => { + if (cancelled) return; + setPolicyConfig(cfg); + }) + .catch((e) => { + if (cancelled) return; + setPolicyConfig(null); + setPolicyConfigError(e instanceof Error ? e.message : String(e)); + }) + .finally(() => { + if (!cancelled) setPolicyConfigLoading(false); + }); + return () => { + cancelled = true; + }; + }, [open, baseUrl, fetchWithHeaders, jobId, selectedStep]); + + const selectedRef = + selectedStep != null + ? checkpoints.find((c) => c.step === selectedStep)?.ref ?? null + : null; + + const cameraError = robot ? cameraConfigurationError(robot.cameras ?? []) : null; + + const canStart = + !!robot && + robot.is_clean && + !cameraError && + selectedRef != null && + !!policyConfig && + cameraBindings.allBound && + !submitting; + + const handleStart = async () => { + if (!robot || selectedRef == null || !policyConfig || cameraError) return; + // Setting submitting=true makes every CameraPreview drop its + // browser stream — required so the rollout subprocess can open the + // same camera index via OpenCV without colliding on the device. + setSubmitting(true); + await new Promise((r) => setTimeout(r, 300)); + try { + await startInference(baseUrl, fetchWithHeaders, { + follower_port: robot.follower_port, + follower_config: robot.follower_config, + policy_ref: selectedRef, + task, + cameras: cameraBindings.toCameraDict(DEFAULT_FPS), + duration_s: durationS, + }); + onOpenChange(false); + navigate("/inference"); + } catch (e) { + toast({ + title: "Couldn't start inference", + description: e instanceof Error ? e.message : String(e), + variant: "destructive", + }); + // Failure: bring the previews back so the user can adjust. + setSubmitting(false); + } + }; + + return ( + + + +
+
+ +
+
+ + Configure Inference + +
+ +
+ + Pick a checkpoint and confirm hardware. The selected policy will + drive the follower autonomously for the configured duration. + + {policyConfig && !BUILTIN_POLICY_TYPES.has(policyConfig.policy_type ?? "") && ( +

+ This is a plugin policy: its package must be installed alongside + lelab, or the run will fail to load the policy. +

+ )} + +
+

+ Robot Configuration +

+ {!robot ? ( + + + + Select and configure a robot on the Landing page first. + + + ) : !robot.is_clean ? ( + + + + {robot.name} is missing a calibration. + Configure it before running inference. + + + ) : ( +
+ + + Running on {robot.name} + +
+ )} +
+ +
+

+ Checkpoint +

+ {checkpoints.length === 0 ? ( + + + + No checkpoints available for this job yet. + + + ) : ( + + )} +
+ +
+

+ Run parameters +

+ {policyConfig?.requires_task ? ( +
+ + setTask(e.target.value)} + placeholder="e.g., pick up the red block" + className="bg-gray-800 border-gray-700 text-white" + /> +

+ This policy is language-conditioned ({policyConfig.policy_type}). +

+
+ ) : null} +
+ + { + if (v !== undefined) setDurationS(v); + }} + className="bg-gray-800 border-gray-700 text-white" + /> +
+
+ +
+

+ Cameras +

+ {cameraError ? ( + + + {cameraError} Correct the camera settings in Calibration. + + ) : policyConfigLoading ? ( +
+ + Reading policy config… +
+ ) : policyConfigError ? ( + + + + Couldn't load policy config: {policyConfigError} + + + ) : !policyConfig ? null : Object.keys(policyConfig.image_features).length === 0 ? ( +

+ This policy doesn't use cameras. +

+ ) : ( +
+

+ Bind a physical camera to each name the policy was trained + with. Resolution comes from the checkpoint. Use the same + rotation as the training data. +

+ +
+ )} +
+ +
+ + +
+
+
+
+ ); +}; + +export default InferenceModal; diff --git a/src/components/landing/LandingTopBar.tsx b/src/components/landing/LandingTopBar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a36321f62ee47908771262623756941063ac9e4c --- /dev/null +++ b/src/components/landing/LandingTopBar.tsx @@ -0,0 +1,28 @@ +import React from "react"; +import HfAuthChip from "./HfAuthChip"; +import TourLauncher from "@/components/onboarding/TourLauncher"; + +const LandingTopBar: React.FC = () => { + return ( +
+
+
+ LeLab + + LeLab + +
+
+ + +
+
+
+ ); +}; + +export default LandingTopBar; diff --git a/src/components/landing/RecordingModal.tsx b/src/components/landing/RecordingModal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0cff4ee1a7610ea7641758bb9270ff2ebdb5400d --- /dev/null +++ b/src/components/landing/RecordingModal.tsx @@ -0,0 +1,351 @@ +import React from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { NumberInput } from "@/components/ui/number-input"; +import { Label } from "@/components/ui/label"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { AlertTriangle, CheckCircle, ChevronDown } from "lucide-react"; +import { cameraConfigurationError } from "@/lib/cameraConfig"; +import CameraConfiguration, { + CameraConfig, +} from "@/components/recording/CameraConfiguration"; +import { useHfAuth } from "@/contexts/HfAuthContext"; +import { RobotRecord } from "@/hooks/useRobots"; +import { AppendTarget } from "@/hooks/useRecording"; +import { useCameraBindings } from "@/hooks/useCameraBindings"; +import CameraBindings from "./CameraBindings"; + +interface RecordingModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + robot: RobotRecord | null; + appendTo: AppendTarget | null; + cameraBindings: ReturnType; + datasetName: string; + setDatasetName: (value: string) => void; + singleTask: string; + setSingleTask: (value: string) => void; + numEpisodes: number; + setNumEpisodes: (value: number) => void; + episodeTimeS: number; + setEpisodeTimeS: (value: number) => void; + resetTimeS: number; + setResetTimeS: (value: number) => void; + streamingEncoding: boolean; + setStreamingEncoding: (value: boolean) => void; + cameras: CameraConfig[]; + setCameras: (cameras: CameraConfig[]) => void; + onStart: () => void; + releaseStreamsRef?: React.MutableRefObject<(() => void) | null>; +} + +const RecordingModal: React.FC = ({ + open, + onOpenChange, + robot, + appendTo, + cameraBindings, + datasetName, + setDatasetName, + singleTask, + setSingleTask, + numEpisodes, + setNumEpisodes, + episodeTimeS, + setEpisodeTimeS, + resetTimeS, + setResetTimeS, + streamingEncoding, + setStreamingEncoding, + cameras, + setCameras, + onStart, + releaseStreamsRef, +}) => { + const { auth } = useHfAuth(); + + const canStart = + !!robot && robot.is_clean && (!appendTo || cameraBindings.allBound); + + return ( + + + +
+
+ REC +
+
+ + {appendTo ? "Record More Episodes" : "Configure Recording"} + +
+
+ + {appendTo ? ( + <> + New episodes are added to{" "} + {appendTo.repoId} + , which has {appendTo.numEpisodes} so far. + + ) : ( + "Pick a configured robot and dataset parameters for recording." + )} + + +
+
+

+ Robot Configuration +

+ {!robot ? ( + + + + Select and configure a robot on the Landing page before + recording. + + + ) : !robot.is_clean ? ( + + + + {robot.name} is missing a calibration. + Configure it before recording. + + + ) : ( +
+ + + Recording with {robot.name} + +
+ )} +
+ +
+

+ Dataset Configuration +

+
+ {!appendTo && ( +
+ + + setDatasetName( + e.target.value.replace(/[^A-Za-z0-9._-]/g, "_") + ) + } + placeholder="my_dataset" + className="bg-gray-800 border-gray-700 text-white" + /> +

+ Letters, numbers, . _{" "} + - only — other characters become{" "} + _. +

+ {datasetName && + (auth.status === "authenticated" ? ( +

+ Will be saved as{" "} + + {auth.username}/{datasetName} + +

+ ) : auth.status === "unauthenticated" ? ( +

+ Log in to Hugging Face to set the repository owner. +

+ ) : null)} +
+ )} +
+ + setSingleTask(e.target.value)} + placeholder="e.g., pick up the red block and place it on the blue square" + className="bg-gray-800 border-gray-700 text-white" + /> +
+
+ + { + if (v !== undefined) setNumEpisodes(v); + }} + className="bg-gray-800 border-gray-700 text-white" + /> +
+
+
+ + { + if (v !== undefined) setEpisodeTimeS(v); + }} + className="bg-gray-800 border-gray-700 text-white" + /> +
+
+ + { + if (v !== undefined) setResetTimeS(v); + }} + className="bg-gray-800 border-gray-700 text-white" + /> +
+
+
+
+ + {appendTo ? ( + Object.keys(appendTo.imageFeatures).length > 0 && + !(robot && cameraConfigurationError(robot.cameras ?? [])) && ( +
+

+ Cameras +

+

+ Bind a physical camera to each camera the dataset was + recorded with. Resolution comes from the dataset. +

+ +
+ ) + ) : ( +
+ +

+ These are the cameras set up for this robot. To add or change a + camera, configure it on the Calibration page. Save a reference + frame to see it as a ghost over the live preview and realign + the rig next session. +

+
+ )} + + + + Advanced Parameters + + + +
+ + setStreamingEncoding(value === true) + } + className="mt-0.5 border-gray-500 data-[state=checked]:bg-red-500 data-[state=checked]:border-red-500" + /> +
+ +

+ Encodes frames in real time during capture so each + episode saves almost instantly. Uncheck to fall back to + the slower PNG-then-encode flow. +

+
+
+
+
+
+ +
+ + +
+
+
+
+ ); +}; + +export default RecordingModal; diff --git a/src/components/landing/RobotConfigManager.tsx b/src/components/landing/RobotConfigManager.tsx new file mode 100644 index 0000000000000000000000000000000000000000..fed18c4dbd09bef3ecee267e1ff4bca2a29d501f --- /dev/null +++ b/src/components/landing/RobotConfigManager.tsx @@ -0,0 +1,88 @@ +import React from "react"; +import { useNavigate } from "react-router-dom"; +import { useApi } from "@/contexts/ApiContext"; +import { useToast } from "@/hooks/use-toast"; +import { RobotRecord } from "@/hooks/useRobots"; +import RobotTile from "./RobotTile"; + +interface RobotConfigManagerProps { + selectedName: string | null; + selectedRecord: RobotRecord | null; + availableNames: string[]; + isLoading: boolean; + selectRobot: (name: string) => void; + createRobot: (name: string) => Promise; + deleteRobot: (name: string) => Promise; +} + +const RobotConfigManager: React.FC = ({ + selectedName, + selectedRecord, + availableNames, + isLoading, + selectRobot, + createRobot, + deleteRobot, +}) => { + const navigate = useNavigate(); + const { baseUrl, fetchWithHeaders } = useApi(); + const { toast } = useToast(); + + const handleConfigure = (name: string) => { + navigate("/calibration", { state: { robot_name: name } }); + }; + + const handleTeleop = async (robot: RobotRecord) => { + try { + const res = await fetchWithHeaders(`${baseUrl}/move-arm`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + leader_port: robot.leader_port, + follower_port: robot.follower_port, + leader_config: robot.leader_config, + follower_config: robot.follower_config, + }), + }); + const data = await res.json(); + // The backend returns HTTP 200 with `{ success: false }` for logical + // failures (arm not connected, already active), so gate on `data.success` + // — not just `res.ok` — or we'd navigate to an empty teleop screen. + if (res.ok && data.success) { + toast({ + title: "Teleoperation Started", + description: data.message || `Started teleoperation for ${robot.name}.`, + }); + navigate("/teleoperation"); + } else { + toast({ + title: "Error Starting Teleoperation", + description: data.message || "Failed to start.", + variant: "destructive", + }); + } + } catch (e) { + toast({ + title: "Connection Error", + description: "Could not connect to the backend server.", + variant: "destructive", + }); + } + }; + + return ( + + ); +}; + +export default RobotConfigManager; diff --git a/src/components/landing/RobotSelector.tsx b/src/components/landing/RobotSelector.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7bdf1e254c51b3cb4c3c11d630e0865f3aa4e40b --- /dev/null +++ b/src/components/landing/RobotSelector.tsx @@ -0,0 +1,144 @@ +import React, { useState } from "react"; +import { Plus, Check, ChevronsUpDown } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { cn } from "@/lib/utils"; + +interface RobotSelectorProps { + selectedName: string | null; + availableNames: string[]; + onSelect: (name: string) => void; + onCreateNew: (name: string) => Promise; + isLoading: boolean; +} + +const RobotSelector: React.FC = ({ + selectedName, + availableNames, + onSelect, + onCreateNew, + isLoading, +}) => { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + + const trimmed = query.trim(); + const matchesExisting = availableNames.some( + (n) => n.toLowerCase() === trimmed.toLowerCase() + ); + const canCreate = trimmed.length > 0 && !matchesExisting; + + const createDisabled = !canCreate; + const createLabel = matchesExisting + ? "Already exists" + : trimmed === "" + ? "Create new robot…" + : `Create "${trimmed}"`; + + const reset = () => { + setQuery(""); + setOpen(false); + }; + + const handlePickExisting = (name: string) => { + onSelect(name); + reset(); + }; + + const handleCreate = async () => { + if (!canCreate) return; + const ok = await onCreateNew(trimmed); + if (ok) reset(); + }; + + return ( + + + + + + + { + if (e.key === "Enter" && canCreate) { + e.preventDefault(); + handleCreate(); + } + }} + className="text-white" + /> + + {availableNames.length === 0 && ( + + No robots yet. Type a name to create one. + + )} + {availableNames.length > 0 && ( + + {availableNames.map((name) => ( + handlePickExisting(name)} + className="text-white aria-selected:bg-gray-700" + > + + {name} + + ))} + + )} + + + + + + ); +}; + +export default RobotSelector; diff --git a/src/components/landing/RobotTile.tsx b/src/components/landing/RobotTile.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f8790c985ad15304914cf5e1ff7c10c18f1bcc78 --- /dev/null +++ b/src/components/landing/RobotTile.tsx @@ -0,0 +1,161 @@ +import React, { useState } from "react"; +import { Settings, Trash2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { RobotRecord } from "@/hooks/useRobots"; +import RobotSelector from "./RobotSelector"; + +interface RobotTileProps { + robot: RobotRecord | null; + selectedName: string | null; + availableNames: string[]; + isLoading: boolean; + onSelect: (name: string) => void; + onCreateNew: (name: string) => Promise; + onConfigure: (name: string) => void; + onTeleop: (robot: RobotRecord) => void; + onDelete: (name: string) => void; +} + +const RobotTile: React.FC = ({ + robot, + selectedName, + availableNames, + isLoading, + onSelect, + onCreateNew, + onConfigure, + onTeleop, + onDelete, +}) => { + const [confirmDelete, setConfirmDelete] = useState(false); + const status = robot ? (robot.is_clean ? "Ready" : "Needs configuration") : null; + const teleopDisabled = !robot || !robot.is_clean; + + return ( +
+
+
+ +
+ {status && ( +

+ {status} +

+ )} + {robot && ( +
+ + + + + Configure (calibrate) + + + + + + Delete robot config + +
+ )} +
+ + {robot && ( + + +
+ +
+
+ {teleopDisabled && ( + Configure the robot first. + )} +
+ )} + + {robot && ( + + + + Delete robot config? + + This deletes the robot config file from disk. Calibration files + are not removed. This cannot be undone. + + + + + + + + + )} +
+ ); +}; + +export default RobotTile; diff --git a/src/components/landing/UsageInstructionsModal.tsx b/src/components/landing/UsageInstructionsModal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..362aa60f34ffb644b680be63a67b1d9feca88bb9 --- /dev/null +++ b/src/components/landing/UsageInstructionsModal.tsx @@ -0,0 +1,107 @@ +import React, { useState } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Terminal, ExternalLink, Copy, Check } from "lucide-react"; + +const ONE_LINER = + "uv tool install --with-executables-from huggingface_hub git+https://github.com/huggingface/leLab.git && lelab"; +const LOCAL_URL = "http://localhost:8000/"; + +interface UsageInstructionsModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + dismissible?: boolean; +} + +const UsageInstructionsModal: React.FC = ({ + open, + onOpenChange, + dismissible = true, +}) => { + const [copied, setCopied] = useState(false); + + const blockClose = (e: Event) => { + if (!dismissible) e.preventDefault(); + }; + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(ONE_LINER); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch (err) { + console.warn("Clipboard write failed:", err); + } + }; + + return ( + undefined} + > + + + + + Get Started with LeLab + + + LeLab runs on your machine. Click the command to copy it, then paste + in a terminal: + + +
+ +

+ After running, your browser will open the local LeLab app. +

+ +
+
+
+ ); +}; + +export default UsageInstructionsModal; diff --git a/src/components/onboarding/SpotlightOverlay.tsx b/src/components/onboarding/SpotlightOverlay.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e5c3d9d3b6615acb96c47a6b359dac87816dfccf --- /dev/null +++ b/src/components/onboarding/SpotlightOverlay.tsx @@ -0,0 +1,129 @@ +import React, { useEffect, useLayoutEffect, useState } from "react"; +import { createPortal } from "react-dom"; +import { useLocation } from "react-router-dom"; +import { useOnboarding } from "@/contexts/OnboardingContext"; +import TourCard from "@/components/onboarding/TourCard"; + +// Padding around the highlighted element, in px. +const PAD = 8; +// How long to wait for a step's target to appear (e.g. after a route change) +// before falling back to a centered card so the tour never blocks. +const MOUNT_TIMEOUT_MS = 3000; + +const SpotlightOverlay: React.FC = () => { + const { isActive, currentStep, stop } = useOnboarding(); + const location = useLocation(); + const [rect, setRect] = useState(null); + // Becomes true once we've either located the target or given up (fallback). + const [ready, setReady] = useState(false); + + const stepId = currentStep?.id ?? null; + const target = currentStep?.target ?? null; + + // Locate the step's target, tracking it across scroll/resize/layout shifts. + // Re-runs whenever the step changes, and on route changes so a manual + // navigation away mid-step re-queries (rather than pinning a stale rect). + useLayoutEffect(() => { + setReady(false); + setRect(null); + if (!isActive || !stepId) return; + + // Centered steps (welcome/done) have no target. + if (!target) { + setReady(true); + return; + } + + let raf = 0; + let observer: ResizeObserver | null = null; + let tracked: Element | null = null; + const startedAt = performance.now(); + + const measure = () => { + if (!tracked) return; + // The element can leave the DOM under us (e.g. the user navigated away + // before this effect re-runs). Drop the spotlight instead of pinning it + // to a stale position. + if (!tracked.isConnected) { + setRect(null); + return; + } + setRect(tracked.getBoundingClientRect()); + }; + + const attachTrackers = (el: Element) => { + tracked = el; + // Bring the target on-screen if it's below the fold (e.g. the cameras or + // jobs step). "nearest" scrolls the minimum needed and does nothing when + // it's already visible; the scroll listener below keeps the cutout and + // card glued to it as the smooth scroll settles. + el.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "nearest" }); + measure(); + setReady(true); + window.addEventListener("scroll", measure, true); + window.addEventListener("resize", measure); + observer = new ResizeObserver(measure); + observer.observe(el); + }; + + const poll = () => { + const el = document.querySelector(`[data-tour="${target}"]`); + if (el) { + attachTrackers(el); + return; + } + if (performance.now() - startedAt > MOUNT_TIMEOUT_MS) { + // Target never mounted — show the copy centered rather than block. + setRect(null); + setReady(true); + return; + } + raf = requestAnimationFrame(poll); + }; + poll(); + + return () => { + if (raf) cancelAnimationFrame(raf); + window.removeEventListener("scroll", measure, true); + window.removeEventListener("resize", measure); + observer?.disconnect(); + }; + }, [isActive, stepId, target, location.pathname]); + + // Esc leaves the tour from anywhere. + useEffect(() => { + if (!isActive) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") stop(false); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [isActive, stop]); + + if (!isActive || !currentStep || !ready) return null; + + const hasRect = !!rect && rect.width > 0 && rect.height > 0; + + return createPortal( +
+ {hasRect ? ( +
+ ) : ( +
+ )} + +
, + document.body + ); +}; + +export default SpotlightOverlay; diff --git a/src/components/onboarding/TourCard.tsx b/src/components/onboarding/TourCard.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f2df23094b66d281c8e8215ca03c0d34bf669905 --- /dev/null +++ b/src/components/onboarding/TourCard.tsx @@ -0,0 +1,185 @@ +import React, { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { Check, Lock, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { useOnboarding } from "@/contexts/OnboardingContext"; + +const MARGIN = 8; +const GAP = 12; +const CARD_W = 340; + +const clamp = (v: number, lo: number, hi: number) => + Math.max(lo, Math.min(hi, v)); + +interface TourCardProps { + /** Bounding rect of the highlighted element, or null for a centered card. */ + rect: DOMRect | null; +} + +const TourCard: React.FC = ({ rect }) => { + const { + currentStep, + stepIndex, + totalSteps, + currentComplete, + currentGated, + next, + back, + skipStep, + stop, + } = useOnboarding(); + const cardRef = useRef(null); + const [size, setSize] = useState({ w: CARD_W, h: 0 }); + + useLayoutEffect(() => { + if (cardRef.current) { + const r = cardRef.current.getBoundingClientRect(); + setSize({ w: r.width, h: r.height }); + } + }, [currentStep?.id, rect]); + + // Move focus to the card when a step opens so keyboard and screen-reader + // users land on the guidance. The card remounts per step (keyed by id), so + // this fires once per step. + useEffect(() => { + cardRef.current?.focus(); + }, []); + + if (!currentStep) return null; + + const placement = currentStep.placement ?? "bottom"; + const centered = !rect || placement === "center"; + const isLast = stepIndex >= totalSteps - 1; + const isFirst = stepIndex <= 0; + + let pos: React.CSSProperties; + if (centered) { + pos = { top: "50%", left: "50%", transform: "translate(-50%, -50%)" }; + } else { + const vw = window.innerWidth; + const vh = window.innerHeight; + let top = 0; + let left = 0; + switch (placement) { + case "top": + top = rect!.top - size.h - GAP; + left = rect!.left + rect!.width / 2 - size.w / 2; + break; + case "left": + left = rect!.left - size.w - GAP; + top = rect!.top + rect!.height / 2 - size.h / 2; + break; + case "right": + left = rect!.right + GAP; + top = rect!.top + rect!.height / 2 - size.h / 2; + break; + case "bottom": + default: + top = rect!.bottom + GAP; + left = rect!.left + rect!.width / 2 - size.w / 2; + break; + } + // Flip to the opposite side if the preferred one overflows the viewport. + if (placement === "bottom" && top + size.h > vh - MARGIN) + top = rect!.top - size.h - GAP; + if (placement === "top" && top < MARGIN) top = rect!.bottom + GAP; + if (placement === "right" && left + size.w > vw - MARGIN) + left = rect!.left - size.w - GAP; + if (placement === "left" && left < MARGIN) left = rect!.right + GAP; + // Keep the card fully on screen. + left = clamp(left, MARGIN, vw - size.w - MARGIN); + top = clamp(top, MARGIN, vh - size.h - MARGIN); + pos = { top, left }; + } + + return ( +
+ + +
+
+

+ Step {stepIndex + 1} of {totalSteps} +

+ {currentComplete && ( + + + Done + + )} +
+

+ {currentStep.title} +

+

+ {currentStep.body} +

+ + {currentGated && !currentComplete && ( +

+ + Finish the earlier step to unlock this, or continue with Next + whenever you like. +

+ )} + +
+ +
+ {currentStep.optional && !isLast && ( + + )} + +
+
+
+
+ ); +}; + +export default TourCard; diff --git a/src/components/onboarding/TourLauncher.tsx b/src/components/onboarding/TourLauncher.tsx new file mode 100644 index 0000000000000000000000000000000000000000..041d059eb459bfea0be5a4f3e864baf7c8b3151d --- /dev/null +++ b/src/components/onboarding/TourLauncher.tsx @@ -0,0 +1,58 @@ +import React from "react"; +import { HelpCircle } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { useOnboarding } from "@/contexts/OnboardingContext"; + +interface TourLauncherProps { + /** "floating" pins a round button to the corner on every page; "inline" is + * a compact button for the landing top bar. */ + variant?: "floating" | "inline"; + className?: string; +} + +const TourLauncher: React.FC = ({ + variant = "inline", + className, +}) => { + const { start, isActive } = useOnboarding(); + + // The floating button would only get in the way while the tour is running. + if (variant === "floating" && isActive) return null; + + if (variant === "floating") { + return ( + + ); + } + + return ( + + ); +}; + +export default TourLauncher; diff --git a/src/components/onboarding/WelcomeDialog.tsx b/src/components/onboarding/WelcomeDialog.tsx new file mode 100644 index 0000000000000000000000000000000000000000..17c839f41d2f4405bd68a689ded6e64bb5ee462f --- /dev/null +++ b/src/components/onboarding/WelcomeDialog.tsx @@ -0,0 +1,77 @@ +import React from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Compass, Route } from "lucide-react"; + +interface WelcomeDialogProps { + open: boolean; + onStart: () => void; + onDismiss: () => void; +} + +// The stages a newcomer will walk through, shown as a quick preview so the +// tour's value is clear before they commit two minutes to it. +const STAGES = ["Calibrate", "Record", "Train", "Run"]; + +const WelcomeDialog: React.FC = ({ + open, + onStart, + onDismiss, +}) => { + return ( + (next ? undefined : onDismiss())}> + + + + + Welcome to LeLab + + + New to teaching a robot arm? A two-minute guided tour walks you + through the whole loop, no jargon required. + + + +
+
+ {STAGES.map((stage, i) => ( + + + {stage} + + {i < STAGES.length - 1 && ( + → + )} + + ))} +
+
+ +
+ + +
+
+
+ ); +}; + +export default WelcomeDialog; diff --git a/src/components/recording/CameraConfiguration.test.tsx b/src/components/recording/CameraConfiguration.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..571552e9ce0849c38d333541489311a48517dda9 --- /dev/null +++ b/src/components/recording/CameraConfiguration.test.tsx @@ -0,0 +1,61 @@ +// @vitest-environment jsdom +import React, { act } from "react"; +import { createRoot, Root } from "react-dom/client"; +import { afterEach, beforeEach, expect, test, vi } from "vitest"; +import CameraConfiguration from "./CameraConfiguration"; +import { CameraConfig, normalizeCameraConfig, serializeCameras } from "@/lib/cameraConfig"; +vi.mock("@/hooks/useAvailableCameras", () => ({ useAvailableCameras: () => ({ cameras: [], isLoading: false, refresh: vi.fn() }) })); +vi.mock("@/hooks/use-toast", () => ({ useToast: () => ({ toast: vi.fn() }) })); +const reference = { url: null as string | null, error: null, save: vi.fn(), clear: vi.fn() }; +vi.mock("@/hooks/useCameraReference", () => ({ useCameraReference: () => reference })); +vi.mock("@/hooks/useCameraStream", () => ({ useCameraStream: () => ({ videoRef: React.createRef(), hasError: false }) })); +let root: Root; +let container: HTMLDivElement; +const camera: CameraConfig = { id: "wrist", name: "wrist", device_id: "usb-wrist", type: "opencv", width: 640, height: 480 }; +beforeEach(() => { Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); container = document.createElement("div"); root = createRoot(container); }); +afterEach(() => act(() => root.unmount())); +test("rotation updates the profile while capture dimensions stay native", () => { + const change = vi.fn(); + act(() => root.render()); + const select = container.querySelector("select")!; + expect(select.value).toBe("0"); + act(() => { select.value = "90"; select.dispatchEvent(new Event("change", { bubbles: true })); }); + expect(change).toHaveBeenCalledWith([{ ...camera, rotation: 90 }]); + const saved = JSON.parse(JSON.stringify(change.mock.calls[0][0])).map(normalizeCameraConfig); + act(() => root.render()); + expect(container.querySelector("select")!.value).toBe("90"); + expect(container.querySelector("video")!.style.transform).toContain("rotate(90deg)"); + expect(serializeCameras(saved).wrist).toMatchObject({ rotation: 90, width: 640, height: 480 }); +}); +test("the recording configuration displays rotated output without an editing control", () => { + act(() => root.render()); + expect(container.textContent).toContain("480×640 · 90° clockwise"); + expect(container.querySelector("select")).toBeNull(); + expect(container.querySelector("video")!.parentElement!.style.aspectRatio).toBe("480 / 640"); +}); + +test("an invalid stored rotation stays visible until the user selects a valid value", () => { + const change = vi.fn(); + const invalid = { ...camera, rotation: 45 as never }; + act(() => root.render()); + expect(container.querySelector("[role=alert]")!.textContent).toContain("Select a valid rotation"); + expect(container.querySelector("video")).toBeNull(); + const select = container.querySelector("select")!; + expect(select.value).toBe(""); + act(() => { select.value = "180"; select.dispatchEvent(new Event("change", { bubbles: true })); }); + expect(change).toHaveBeenCalledWith([{ ...camera, rotation: 180 }]); +}); +test("reference controls appear only for a named robot in read-only mode", () => { + act(() => root.render()); + expect(container.textContent).not.toContain("Set reference"); + act(() => root.render()); + expect(container.textContent).toContain("Set reference"); + expect(container.querySelector('input[type="range"]')).toBeNull(); + reference.url = "blob:ref"; + act(() => root.render()); + expect(container.textContent).toContain("Replace reference"); + expect(container.querySelector("img")!.getAttribute("src")).toBe("blob:ref"); + const slider = container.querySelector('input[type="range"]')!; + expect(slider).not.toBeNull(); + reference.url = null; +}); diff --git a/src/components/recording/CameraConfiguration.tsx b/src/components/recording/CameraConfiguration.tsx new file mode 100644 index 0000000000000000000000000000000000000000..60c6b6907e83fe0c12e3b8e8aae250abfdf6cb98 --- /dev/null +++ b/src/components/recording/CameraConfiguration.tsx @@ -0,0 +1,538 @@ +import React, { useState, useEffect, useCallback, useRef } from "react"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Input } from "@/components/ui/input"; +import { NumberInput } from "@/components/ui/number-input"; +import { Camera, Plus, X, RefreshCw, ChevronRight } from "lucide-react"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { useToast } from "@/hooks/use-toast"; +import { useAvailableCameras } from "@/hooks/useAvailableCameras"; +import { useCameraReference } from "@/hooks/useCameraReference"; +import BrowserCameraPreview from "@/components/control/BrowserCameraPreview"; +import CameraRotationSelect from "@/components/control/CameraRotationSelect"; +import CameraReferenceControls from "@/components/recording/CameraReferenceControls"; +import { + CameraConfig, + cameraRotation, + cameraRotationError, + previewDeviceId, + rotatedDimensions, +} from "@/lib/cameraConfig"; +export type { CameraConfig } from "@/lib/cameraConfig"; + +// Sentinels distinguish "leave unset" (auto-detect / platform default) from an +// explicit choice. Radix Select disallows an empty-string value, so we map these +// to `undefined` on the CameraConfig. +const FOURCC_AUTO = "__auto__"; +const BACKEND_DEFAULT = "__default__"; +const FOURCC_OPTIONS = ["MJPG", "YUYV", "I420", "NV12", "H264", "MP4V"]; +// Mirrors lerobot's Cv2Backends enum names. +const BACKEND_OPTIONS = [ + "ANY", + "V4L2", + "DSHOW", + "PVAPI", + "ANDROID", + "AVFOUNDATION", + "MSMF", +]; + +interface CameraConfigurationProps { + cameras: CameraConfig[]; + onCamerasChange: (cameras: CameraConfig[]) => void; + releaseStreamsRef?: React.MutableRefObject<(() => void) | null>; // Ref to expose stream release function + // When true, hide the add/remove/edit controls and show the configured + // cameras as read-only live previews. Used in the recording dialog, where + // cameras are managed on the Calibration page rather than added ad hoc. + readOnly?: boolean; + // Robot the cameras belong to. In read-only mode this enables the reference + // frame controls, which are stored per robot and camera. + robotName?: string; +} + +const CameraConfiguration: React.FC = ({ + cameras, + onCamerasChange, + releaseStreamsRef, + readOnly = false, + robotName, +}) => { + const { toast } = useToast(); + + const { + cameras: availableCameras, + isLoading: isLoadingCameras, + refresh: refreshCameras, + } = useAvailableCameras(); + const [selectedCameraIndex, setSelectedCameraIndex] = useState(""); + const [cameraName, setCameraName] = useState(""); + + // cv2's AVFoundation order is uniqueID-sorted, so plugging/unplugging a + // device between sessions shifts indices. The browser device_id stays + // stable per-origin, so use it to refresh each seeded camera's + // camera_index — otherwise the recorder opens the wrong physical device + // and the dropdown's "already added" check guards a stale index. + useEffect(() => { + if (availableCameras.length === 0 || cameras.length === 0) return; + let changed = false; + const refreshed = cameras.map((cam) => { + if (!cam.device_id) return cam; + const match = availableCameras.find((m) => m.deviceId === cam.device_id); + if (match && match.index !== cam.camera_index) { + changed = true; + return { ...cam, camera_index: match.index }; + } + return cam; + }); + if (changed) onCamerasChange(refreshed); + // We deliberately don't depend on `cameras`/`onCamerasChange` to avoid + // re-running every keystroke in the camera-name input — re-syncing only + // when the available-cameras list itself changes is sufficient. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [availableCameras]); + + const addCamera = () => { + if (!selectedCameraIndex || !cameraName.trim()) { + toast({ + title: "Missing Information", + description: "Please select a camera and provide a name.", + variant: "destructive", + }); + return; + } + + const cameraIndex = parseInt(selectedCameraIndex); + const selectedCamera = availableCameras.find( + (cam) => cam.index === cameraIndex + ); + + if (!selectedCamera) { + toast({ + title: "Invalid Camera", + description: "Selected camera is not available.", + variant: "destructive", + }); + return; + } + + // Block duplicates by either cv2 index or browser deviceId — a stale + // camera_index in a seeded camera can otherwise let the same physical + // device sneak in under a different index. + const isDuplicate = cameras.some( + (cam) => + cam.camera_index === selectedCamera.index || + (selectedCamera.deviceId && cam.device_id === selectedCamera.deviceId), + ); + if (isDuplicate) { + toast({ + title: "Camera Already Added", + description: "This camera is already in the configuration.", + variant: "destructive", + }); + return; + } + + const newCamera: CameraConfig = { + id: `camera_${Date.now()}`, + name: cameraName.trim(), + type: "opencv", + camera_index: selectedCamera.index, + device_id: selectedCamera.deviceId, + width: 640, + height: 480, + fps: 30, + rotation: 0, + }; + + onCamerasChange([...cameras, newCamera]); + + setSelectedCameraIndex(""); + setCameraName(""); + + toast({ + title: "Camera Added", + description: `${newCamera.name} has been added to the configuration.`, + }); + }; + + const removeCamera = (cameraId: string) => { + onCamerasChange(cameras.filter((cam) => cam.id !== cameraId)); + toast({ + title: "Camera Removed", + description: "Camera has been removed from the configuration.", + }); + }; + + const updateCamera = (cameraId: string, updates: Partial) => { + onCamerasChange( + cameras.map((cam) => + cam.id === cameraId ? { ...cam, ...updates } : cam + ) + ); + }; + + // When the recording session is starting, the parent calls + // releaseStreamsRef.current() to make every CameraPreview drop its browser + // stream so cv2.VideoCapture can grab the camera exclusively. + const [streamsPaused, setStreamsPaused] = useState(false); + const releaseAllCameraStreams = useCallback(() => { + setStreamsPaused(true); + }, []); + + useEffect(() => { + if (releaseStreamsRef) { + releaseStreamsRef.current = releaseAllCameraStreams; + } + }, [releaseStreamsRef, releaseAllCameraStreams]); + + + return ( +
+

+ {readOnly ? "Cameras" : "Camera Configuration"} +

+ + {/* Add Camera Section — hidden in read-only mode (cameras are managed on + the Calibration page, not added here). */} + {!readOnly && ( +
+

Add Camera

+ +
+
+
+ + +
+ +
+ +
+ + setCameraName(e.target.value)} + placeholder="e.g., workspace_cam" + className="bg-gray-800 border-gray-700 text-white" + /> +
+ +
+ +
+
+
+ )} + + {/* Configured Cameras */} + {cameras.length > 0 && ( +
+ {!readOnly && ( +

+ Configured Cameras ({cameras.length}) +

+ )} + +
+ {cameras.map((camera) => ( + removeCamera(camera.id)} + onUpdate={(updates) => updateCamera(camera.id, updates)} + /> + ))} +
+
+ )} + + {cameras.length === 0 && ( +
+ +

+ {readOnly + ? "No cameras are configured for this robot." + : "No cameras configured. Add a camera to get started."} +

+
+ )} +
+ ); +}; + +interface CameraPreviewProps { + camera: CameraConfig; + deviceId: string; + paused: boolean; + readOnly: boolean; + robotName?: string; + onRemove: () => void; + onUpdate: (updates: Partial) => void; +} + +const CameraPreview: React.FC = ({ + camera, + deviceId, + paused, + readOnly, + robotName, + onRemove, + onUpdate, +}) => { + const previewRef = useRef(null); + const [ghostOpacity, setGhostOpacity] = useState(0.5); + const reference = useCameraReference(readOnly ? robotName : undefined, camera.name); + const rotationError = cameraRotationError(camera); + const rotation = rotationError ? null : cameraRotation(camera.rotation); + const output = rotation === null ? null : rotatedDimensions(camera.width, camera.height, rotation); + return ( +
+
+ +
+ + {/* Camera Info */} +
+
+
{camera.name}
+ {!readOnly && ( + + )} +
+ + {readOnly && robotName && ( + { + const video = previewRef.current?.querySelector("video"); + if (video) void reference.save(video); + }} + onClear={() => void reference.clear()} + disabled={paused || !!rotationError} + error={reference.error} + /> + )} + {rotationError &&

{rotationError} Select a valid rotation.

} + {readOnly ? ( +

+ {output ? `${output.width}×${output.height} · ${rotation}° clockwise` : "Invalid rotation"} + {camera.fps ? ` · ${camera.fps} fps` : ""} +

+ ) : ( + <> + onUpdate({ rotation: value })} /> + + + + Configuration + + +
+
+ Capture: +
+ { + if (v !== undefined) onUpdate({ width: v }); + }} + className="bg-gray-800 border-gray-700 text-white text-xs h-6 px-2 w-16" + min="320" + max="1920" + /> + × + { + if (v !== undefined) onUpdate({ height: v }); + }} + className="bg-gray-800 border-gray-700 text-white text-xs h-6 px-2 w-16" + min="240" + max="1080" + /> +
+
+
+ FPS: + { + if (v !== undefined) onUpdate({ fps: v }); + }} + className="bg-gray-800 border-gray-700 text-white text-xs h-6 px-2 w-16" + min="10" + max="60" + /> +
+
+ FOURCC: + +
+
+ Backend: + +
+

+ Overriding the backend can reorder camera indices on macOS. +

+
+
+ Type: {camera.type} | Device:{" "} + {camera.device_id?.substring(0, 10)}... +
+
+
+ + )} +
+
+ ); +}; + +export default CameraConfiguration; diff --git a/src/components/recording/CameraReferenceControls.tsx b/src/components/recording/CameraReferenceControls.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c23e97acca744ef947cdf88decfb1e6996a09631 --- /dev/null +++ b/src/components/recording/CameraReferenceControls.tsx @@ -0,0 +1,67 @@ +import React from "react"; +import { Button } from "@/components/ui/button"; + +interface Props { + hasReference: boolean; + opacity: number; + onOpacityChange: (opacity: number) => void; + onSave: () => void; + onClear: () => void; + disabled: boolean; + error: string | null; +} + +/** Save the current frame as the reference, then slide the ghost overlay over the live preview. */ +const CameraReferenceControls: React.FC = ({ + hasReference, + opacity, + onOpacityChange, + onSave, + onClear, + disabled, + error, +}) => ( +
+
+ + {hasReference && ( + + )} +
+ {hasReference && ( + + )} + {error &&

{error}

} +
+); + +export default CameraReferenceControls; diff --git a/src/components/replay/DatasetCombobox.tsx b/src/components/replay/DatasetCombobox.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4e5fdb06f68197c1fffb26824eff60a5591eb3c2 --- /dev/null +++ b/src/components/replay/DatasetCombobox.tsx @@ -0,0 +1,114 @@ +import React from "react"; +import { Check, ChevronsUpDown, Pencil } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; +import { cn } from "@/lib/utils"; +import { DatasetItem } from "@/lib/replayApi"; + +interface Props { + datasets: DatasetItem[]; + loading: boolean; + value: string | null; + onChange: (repoId: string | null) => void; +} + +const REPO_ID_RE = /^[\w.-]+\/[\w.-]+$/; + +const DatasetCombobox: React.FC = ({ datasets, loading, value, onChange }) => { + const [open, setOpen] = React.useState(false); + const [customMode, setCustomMode] = React.useState(false); + const [customValue, setCustomValue] = React.useState(""); + + const submitCustom = () => { + const v = customValue.trim(); + if (REPO_ID_RE.test(v)) { + onChange(v); + setCustomMode(false); + } + }; + + const localDatasets = datasets.filter((d) => d.source === "local" || d.source === "both"); + const hubDatasets = datasets.filter((d) => d.source === "hub"); + + const renderItem = (d: DatasetItem) => ( + { onChange(d.repo_id); setOpen(false); }} + className="text-white aria-selected:bg-gray-700" + > + + {d.repo_id} + {d.source === "both" && on Hub} + {d.private && private} + + ); + + if (customMode) { + return ( +
+ setCustomValue(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") submitCustom(); }} + placeholder="org/dataset-name" + className="bg-gray-800 border-gray-600 text-white" + /> + + +
+ ); + } + + return ( + + + + + + + + + {loading ? "Loading…" : "No datasets."} + {localDatasets.length > 0 && ( + + {localDatasets.map(renderItem)} + + )} + {hubDatasets.length > 0 && ( + + {hubDatasets.map(renderItem)} + + )} + + { setCustomMode(true); setOpen(false); }} + className="text-purple-300 aria-selected:bg-gray-700" + > + + Use custom repo ID… + + + + + + + ); +}; + +export default DatasetCombobox; diff --git a/src/components/training/ConfigurationTab.tsx b/src/components/training/ConfigurationTab.tsx new file mode 100644 index 0000000000000000000000000000000000000000..caa0ab8cc801c0a153c2cdd31a2e9887403392e3 --- /dev/null +++ b/src/components/training/ConfigurationTab.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import EssentialsCard from './config/EssentialsCard'; +import GrootCard from './config/GrootCard'; +import AdvancedCard from './config/AdvancedCard'; +import TargetCard from './config/TargetCard'; +import { ConfigComponentProps } from './types'; +import { DatasetItem } from '@/lib/replayApi'; +import { RunnerFlavor } from '@/lib/jobsApi'; + +interface ConfigurationTabProps extends ConfigComponentProps { + datasets: DatasetItem[]; + datasetsLoading: boolean; + authenticated: boolean; + flavors: RunnerFlavor[]; + hardwareLoading: boolean; +} + +const ConfigurationTab: React.FC = ({ + config, + updateConfig, + datasets, + datasetsLoading, + authenticated, + flavors, + hardwareLoading, +}) => { + return ( +
+ + + + +
+ ); +}; + +export default ConfigurationTab; diff --git a/src/components/training/InstallProgress.tsx b/src/components/training/InstallProgress.tsx new file mode 100644 index 0000000000000000000000000000000000000000..25080ee20659e2f5700aaf74911fc10037ed52be --- /dev/null +++ b/src/components/training/InstallProgress.tsx @@ -0,0 +1,176 @@ +import React from "react"; +import { Button } from "@/components/ui/button"; +import { useToast } from "@/hooks/use-toast"; +import { + AlertTriangle, + CheckCircle2, + Copy, + Loader2, + XCircle, +} from "lucide-react"; +import type { InstallState, LogEntry } from "@/hooks/useInstallExtra"; + +interface InstallProgressProps { + state: InstallState; + error: string | null; + logs: LogEntry[]; + logBoxRef: React.RefObject; + onInstall: () => void; + onRetry: () => void; + + installHint: string; + packageName: string; + idleTitle: string; + idleDescription: React.ReactNode; + doneDescription: React.ReactNode; +} + +export function installTitle(state: InstallState, idleTitle: string): string { + switch (state) { + case "done": + return "Install Complete"; + case "error": + return "Install Failed"; + case "installing": + return "Installing…"; + default: + return idleTitle; + } +} + +export function InstallTitleIcon({ state }: { state: InstallState }) { + if (state === "done") return ; + if (state === "error") return ; + if (state === "installing") + return ; + return ; +} + +export const InstallProgress: React.FC = ({ + state, + error, + logs, + logBoxRef, + onInstall, + onRetry, + installHint, + packageName, + idleDescription, + doneDescription, +}) => { + const { toast } = useToast(); + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(installHint); + toast({ title: "Copied", description: installHint }); + } catch { + toast({ + title: "Copy failed", + description: "Select the command and copy manually.", + variant: "destructive", + }); + } + }; + + return ( + <> + {state === "idle" && ( + <> +

{idleDescription}

+
+ + {installHint} + + +
+ + + )} + + {state === "installing" && ( +

+ Installing{" "} + + {packageName} + + . This usually takes about 10 seconds. +

+ )} + + {state === "done" && ( +
{doneDescription}
+ )} + + {state === "error" && ( + <> +

{error || "Install failed."}

+ + + )} + + {state === "error" && logs.length > 0 && ( +
+ {logs.map((log, idx) => ( +
{log.message}
+ ))} +
+ )} + + ); +}; + +export const RestartInstructions: React.FC<{ purpose: string }> = ({ + purpose, +}) => ( + <> +

+ Install complete. Restart{" "} + + lelab + {" "} + to enable {purpose}: +

+
    +
  1. + Press{" "} + + Ctrl+C + {" "} + in the terminal running{" "} + + lelab + + . +
  2. +
  3. + Run{" "} + + lelab + {" "} + again. +
  4. +
+ +); diff --git a/src/components/training/PolicyExtraDialog.tsx b/src/components/training/PolicyExtraDialog.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ff97982a2349ccf10f84aaf406bac8f16bc12972 --- /dev/null +++ b/src/components/training/PolicyExtraDialog.tsx @@ -0,0 +1,81 @@ +import React from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { useInstallExtra } from "@/hooks/useInstallExtra"; +import { + InstallProgress, + InstallTitleIcon, + RestartInstructions, + installTitle, +} from "./InstallProgress"; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + policyType: string; + packageName: string; // the probed module, e.g. "transformers" + installTarget: string; // e.g. "lerobot[smolvla]" + installHint: string; // e.g. "pip install 'lerobot[smolvla]'" +} + +// Some policies (smolvla, pi0, pi0_fast, diffusion) need an optional LeRobot +// extra. This catches the missing package before training starts and offers a +// one-click install, instead of the run dying with a buried ImportError. +const PolicyExtraDialog: React.FC = ({ + open, + onOpenChange, + policyType, + packageName, + installTarget, + installHint, +}) => { + const install = useInstallExtra(`system/policy-extra/${policyType}`, open); + const title = `${policyType.toUpperCase()} needs an extra package`; + + return ( + + + + + + {installTitle(install.state, title)} + + + Install {installTarget} to train {policyType}. + + + +
+ + Training a {policyType} policy needs the{" "} + {packageName}{" "} + package (installed via{" "} + {installTarget}), + which isn't in this environment yet. Install it to train this policy. + + } + doneDescription={} + /> +
+
+
+ ); +}; + +export default PolicyExtraDialog; diff --git a/src/components/training/TrainingExtraGate.tsx b/src/components/training/TrainingExtraGate.tsx new file mode 100644 index 0000000000000000000000000000000000000000..018cac0dfaae657a80a3c1334abf09720e7621af --- /dev/null +++ b/src/components/training/TrainingExtraGate.tsx @@ -0,0 +1,56 @@ +import React from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { useInstallExtra } from "@/hooks/useInstallExtra"; +import { + InstallProgress, + InstallTitleIcon, + RestartInstructions, + installTitle, +} from "./InstallProgress"; + +interface Props { + installHint: string; +} + +const TrainingExtraGate: React.FC = ({ installHint }) => { + const install = useInstallExtra("system/training-extra"); + + return ( +
+ + + + + {installTitle(install.state, "Training Extra Not Installed")} + + + + + Training requires the{" "} + + accelerate + {" "} + package, which isn't installed in this environment. Install it + to enable the Training page. + + } + doneDescription={} + /> + + +
+ ); +}; + +export default TrainingExtraGate; diff --git a/src/components/training/TrainingHeader.tsx b/src/components/training/TrainingHeader.tsx new file mode 100644 index 0000000000000000000000000000000000000000..120eb43ef59bafae489bf82cb979ca0d884a172b --- /dev/null +++ b/src/components/training/TrainingHeader.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Button } from '@/components/ui/button'; +import { ArrowLeft } from 'lucide-react'; +import Logo from '@/components/Logo'; + +const TrainingHeader: React.FC = () => { + const navigate = useNavigate(); + return ( +
+
+ + +

Training

+
+
+ ); +}; + +export default TrainingHeader; diff --git a/src/components/training/WandbInstallDialog.tsx b/src/components/training/WandbInstallDialog.tsx new file mode 100644 index 0000000000000000000000000000000000000000..3ead2f07cd67d3b4ddc7f93324183a98a33e93b9 --- /dev/null +++ b/src/components/training/WandbInstallDialog.tsx @@ -0,0 +1,68 @@ +import React from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { useInstallExtra } from "@/hooks/useInstallExtra"; +import { + InstallProgress, + InstallTitleIcon, + RestartInstructions, + installTitle, +} from "./InstallProgress"; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + installHint: string; +} + +const WandbInstallDialog: React.FC = ({ open, onOpenChange, installHint }) => { + const install = useInstallExtra("system/wandb-extra", open); + + return ( + + + + + + {installTitle(install.state, "Weights & Biases Not Installed")} + + + Install the wandb package to enable W&B logging. + + + +
+ + Enabling W&B logging requires the{" "} + + wandb + {" "} + package, which isn't installed in this environment. Install it + to log this run to W&B. + + } + doneDescription={} + /> +
+
+
+ ); +}; + +export default WandbInstallDialog; diff --git a/src/components/training/config/AdvancedCard.tsx b/src/components/training/config/AdvancedCard.tsx new file mode 100644 index 0000000000000000000000000000000000000000..fee8d443f4308d9a0b1cc179dd64659b2277aa61 --- /dev/null +++ b/src/components/training/config/AdvancedCard.tsx @@ -0,0 +1,345 @@ +import React, { useState } from 'react'; +import { Card, CardContent, CardHeader } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { NumberInput } from '@/components/ui/number-input'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Separator } from '@/components/ui/separator'; +import { ChevronDown, ChevronRight } from 'lucide-react'; +import { ConfigComponentProps } from '../types'; + +const SectionHeading: React.FC<{ children: React.ReactNode }> = ({ children }) => ( +

+ {children} +

+); + +const AdvancedCard: React.FC = ({ config, updateConfig }) => { + const [expanded, setExpanded] = useState(false); + + return ( + + setExpanded((v) => !v)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setExpanded((v) => !v); + } + }} + className="cursor-pointer select-none flex flex-row items-center justify-between" + > + Advanced + + {expanded ? ( + + ) : ( + + )} + {expanded ? 'Hide' : 'Show'} + + + + {expanded && ( + + {/* Policy */} +
+ Policy +
+ {/* An HF Jobs pod picks its own device, so this only applies locally. */} + {config.target.runner === 'local' && ( +
+ + +
+ )} +
+ updateConfig('policy_use_amp', checked)} + /> + +
+
+
+ + + + {/* Training */} +
+ Training +
+
+ + updateConfig('seed', v)} + className="bg-slate-900 border-slate-600 text-white rounded-lg" + /> +
+
+ + { + if (v !== undefined) updateConfig('num_workers', v); + }} + className="bg-slate-900 border-slate-600 text-white rounded-lg" + /> +
+
+
+ + + + {/* Optimizer */} +
+ Optimizer +
+ + +
+
+
+ + updateConfig('optimizer_lr', v)} + placeholder="Use policy default" + className="bg-slate-900 border-slate-600 text-white rounded-lg" + /> +
+
+ + updateConfig('optimizer_weight_decay', v)} + placeholder="Use policy default" + className="bg-slate-900 border-slate-600 text-white rounded-lg" + /> +
+
+ + updateConfig('optimizer_grad_clip_norm', v)} + placeholder="Use policy default" + className="bg-slate-900 border-slate-600 text-white rounded-lg" + /> +
+
+
+ + + + {/* Logging & Checkpointing */} +
+ Logging & Checkpointing +
+
+ + { + if (v !== undefined) updateConfig('log_freq', v); + }} + className="bg-slate-900 border-slate-600 text-white rounded-lg" + /> +
+
+ + { + if (v !== undefined) updateConfig('save_freq', v); + }} + className="bg-slate-900 border-slate-600 text-white rounded-lg" + /> +
+
+
+ updateConfig('save_checkpoint', checked)} + /> + +
+
+ updateConfig('resume', checked)} + /> + +
+
+ + {config.wandb_enable && ( + <> + +
+ Weights & Biases +
+ + + updateConfig('wandb_entity', e.target.value || undefined) + } + placeholder="your-username" + className="bg-slate-900 border-slate-600 text-white rounded-lg" + /> +
+
+ + + updateConfig('wandb_notes', e.target.value || undefined) + } + placeholder="Training run notes..." + className="bg-slate-900 border-slate-600 text-white rounded-lg" + /> +
+
+ + +
+
+ + updateConfig('wandb_disable_artifact', checked) + } + /> + +
+
+ + )} + + {!config.wandb_enable && } + + {/* Misc */} +
+ Misc +
+ + updateConfig('use_policy_training_preset', checked) + } + /> + +
+
+
+ )} +
+ ); +}; + +export default AdvancedCard; diff --git a/src/components/training/config/EssentialsCard.tsx b/src/components/training/config/EssentialsCard.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0103668e28f766ad25202767604e2f53a3571c4f --- /dev/null +++ b/src/components/training/config/EssentialsCard.tsx @@ -0,0 +1,181 @@ +import React, { useState } from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { NumberInput } from '@/components/ui/number-input'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { ConfigComponentProps } from '../types'; +import DatasetCombobox from '@/components/replay/DatasetCombobox'; +import { DatasetItem } from '@/lib/replayApi'; +import WandbInstallDialog from '../WandbInstallDialog'; +import { useApi } from '@/contexts/ApiContext'; + +interface EssentialsCardProps extends ConfigComponentProps { + datasets: DatasetItem[]; + datasetsLoading: boolean; +} + +const EssentialsCard: React.FC = ({ config, updateConfig, datasets, datasetsLoading }) => { + const { baseUrl, fetchWithHeaders } = useApi(); + const [wandbDialogOpen, setWandbDialogOpen] = useState(false); + const [wandbInstallHint, setWandbInstallHint] = useState('pip install wandb'); + + const handleWandbToggle = async (checked: boolean) => { + if (!checked) { + updateConfig('wandb_enable', false); + return; + } + // Check availability before flipping the switch on. If wandb isn't + // importable in this lelab process, surface the same install flow used + // for the training extra (accelerate) instead of letting the user start + // a run that will fail. + try { + const r = await fetchWithHeaders(`${baseUrl}/system/wandb-extra`); + const data: { available: boolean; install_hint: string } = await r.json(); + if (data.available) { + updateConfig('wandb_enable', true); + } else { + setWandbInstallHint(data.install_hint); + setWandbDialogOpen(true); + } + } catch { + // Backend unreachable — let the user proceed; training start will + // surface the real error if wandb is genuinely missing. + updateConfig('wandb_enable', true); + } + }; + + return ( + + + Run Configuration + + +
+ +
+ { + if (repoId) updateConfig('dataset_repo_id', repoId); + }} + /> +
+

+ HuggingFace Hub dataset repository ID +

+
+ +
+
+ + +
+ +
+ + { + if (v !== undefined) updateConfig('steps', v); + }} + className="bg-slate-900 border-slate-600 text-white rounded-lg" + /> +
+ +
+ + { + if (v !== undefined) updateConfig('batch_size', v); + }} + className="bg-slate-900 border-slate-600 text-white rounded-lg" + /> +
+ +
+ + +
+
+ + + + {config.wandb_enable && ( +
+ + + updateConfig('wandb_project', e.target.value || undefined) + } + placeholder="my-robotics-project" + className="bg-slate-900 border-slate-600 text-white rounded-lg" + /> +
+ )} +
+
+ ); +}; + +export default EssentialsCard; diff --git a/src/components/training/config/GrootCard.tsx b/src/components/training/config/GrootCard.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ea820f0e65c77d44ee358817de3d5658a091ae63 --- /dev/null +++ b/src/components/training/config/GrootCard.tsx @@ -0,0 +1,178 @@ +import React, { useEffect, useRef } from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { NumberInput } from '@/components/ui/number-input'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { ConfigComponentProps } from '../types'; + +// Sensible GR00T N1.7 defaults, seeded the first time the user picks the policy +// so the form matches the canonical fine-tuning command out of the box. +const GROOT_DEFAULTS = { + policy_base_model_path: 'nvidia/GR00T-N1.7-3B', + policy_embodiment_tag: 'new_embodiment', + policy_chunk_size: 16, + policy_n_action_steps: 16, + policy_use_relative_actions: true, + policy_relative_exclude_joints: ['gripper'], + policy_use_bf16: true, + dataset_image_transforms_enable: true, +} as const; + +const GrootCard: React.FC = ({ config, updateConfig }) => { + const isGroot = config.policy_type === 'groot'; + const seededDefaults = useRef(false); + + // Seed defaults once when groot is selected. Each key is only written when + // still undefined, so the effect can't loop and never clobbers user edits. + useEffect(() => { + if (!isGroot || seededDefaults.current) return; + seededDefaults.current = true; + (Object.keys(GROOT_DEFAULTS) as (keyof typeof GROOT_DEFAULTS)[]).forEach((key) => { + if (config[key] === undefined) { + updateConfig(key, GROOT_DEFAULTS[key] as never); + } + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isGroot]); + + if (!isGroot) return null; + + const excludeJoints = (config.policy_relative_exclude_joints ?? []).join(', '); + + return ( + + + GR00T N1.7 + + +
+ + + updateConfig('policy_base_model_path', e.target.value || undefined) + } + placeholder="nvidia/GR00T-N1.7-3B" + className="bg-slate-900 border-slate-600 text-white rounded-lg" + /> +

+ HuggingFace repo of the pretrained GR00T backbone to fine-tune. +

+
+ +
+
+ + + updateConfig('policy_embodiment_tag', e.target.value || undefined) + } + placeholder="new_embodiment" + className="bg-slate-900 border-slate-600 text-white rounded-lg" + /> +
+ +
+ + + updateConfig( + 'policy_relative_exclude_joints', + e.target.value + .split(',') + .map((s) => s.trim()) + .filter(Boolean), + ) + } + placeholder="gripper" + className="bg-slate-900 border-slate-600 text-white rounded-lg" + /> +

+ Comma-separated joints kept absolute when relative actions are on. +

+
+ +
+ + updateConfig('policy_chunk_size', v)} + className="bg-slate-900 border-slate-600 text-white rounded-lg" + /> +
+ +
+ + updateConfig('policy_n_action_steps', v)} + className="bg-slate-900 border-slate-600 text-white rounded-lg" + /> +
+
+ +
+
+ + updateConfig('policy_use_relative_actions', checked) + } + className="data-[state=checked]:bg-green-500" + /> + +
+ +
+ updateConfig('policy_use_bf16', checked)} + className="data-[state=checked]:bg-green-500" + /> + +
+ +
+ + updateConfig('dataset_image_transforms_enable', checked) + } + className="data-[state=checked]:bg-green-500" + /> + +
+
+
+
+ ); +}; + +export default GrootCard; diff --git a/src/components/training/config/TargetCard.tsx b/src/components/training/config/TargetCard.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9da59f0cb061616f79d9586b6ed32cfff9629076 --- /dev/null +++ b/src/components/training/config/TargetCard.tsx @@ -0,0 +1,90 @@ +import React from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { ConfigComponentProps } from "../types"; +import { RunnerFlavor } from "@/lib/jobsApi"; + +interface TargetCardProps extends ConfigComponentProps { + authenticated: boolean; + flavors: RunnerFlavor[]; + loading: boolean; +} + +const formatHourly = (unitCostUsd: number, unitLabel: string): string => { + const hourly = unitLabel === "minute" ? unitCostUsd * 60 : unitCostUsd; + return `$${hourly.toFixed(2)}/hr`; +}; + +const formatFlavorLine = (f: RunnerFlavor): string => { + const accel = f.accelerator ? f.accelerator : f.cpu; + return `${f.pretty_name} · ${accel} · ${formatHourly(f.unit_cost_usd, f.unit_label)}`; +}; + +const TargetCard: React.FC = ({ + config, + updateConfig, + authenticated, + flavors, + loading, +}) => { + const target = config.target; + const value = + target.runner === "local" ? "local" : `hf:${target.flavor ?? ""}`; + + const handleChange = (v: string) => { + if (v === "local") { + updateConfig("target", { runner: "local" }); + } else if (v.startsWith("hf:")) { + const flavor = v.slice("hf:".length); + updateConfig("target", { runner: "hf_cloud", flavor }); + } + }; + + return ( + + + Compute target + + +
+ + +

+ Cost shown is per running hour. Final policy uploads to your HF + account when training completes. +

+
+
+
+ ); +}; + +export default TargetCard; diff --git a/src/components/training/monitoring/MonitoringStats.tsx b/src/components/training/monitoring/MonitoringStats.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d7a48b4b6b5de74bc91144a8e7783a9393ae06e6 --- /dev/null +++ b/src/components/training/monitoring/MonitoringStats.tsx @@ -0,0 +1,279 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { TrainingStatus } from '../types'; +import { CheckCircle, Activity, Clock } from 'lucide-react'; +import { useApi } from '@/contexts/ApiContext'; +import { getJobMetricsHistory } from '@/lib/jobsApi'; +import { + Line, + LineChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; + +interface MonitoringStatsProps { + jobId: string; + trainingStatus: TrainingStatus; + getProgressPercentage: () => number; + formatTime: (seconds: number) => string; +} + +interface LossPoint { + step: number; + loss: number; +} + +interface LrPoint { + step: number; + lr: number; +} + +const HISTORY_CAP = 2000; + +const MonitoringStats: React.FC = ({ + jobId, + trainingStatus, + getProgressPercentage, + formatTime, +}) => { + const [lossHistory, setLossHistory] = useState([]); + const [lrHistory, setLrHistory] = useState([]); + const lastStepRef = useRef(0); + const { baseUrl, fetchWithHeaders } = useApi(); + + // Seed the curves from the persisted log on mount (and when the active job + // changes). Without this, the chart starts empty on every page reload, + // after navigating away and back, or after a lelab restart re-attaches to + // a still-running job. Live-append continues from the last seeded step. + useEffect(() => { + let cancelled = false; + getJobMetricsHistory(baseUrl, fetchWithHeaders, jobId) + .then((points) => { + if (cancelled || points.length === 0) return; + const lossSeed: LossPoint[] = points + .filter((p) => p.loss != null) + .map((p) => ({ step: p.step, loss: p.loss as number })) + .slice(-HISTORY_CAP); + const lrSeed: LrPoint[] = points + .filter((p) => p.lr != null) + .map((p) => ({ step: p.step, lr: p.lr as number })) + .slice(-HISTORY_CAP); + setLossHistory(lossSeed); + setLrHistory(lrSeed); + // Pin lastStepRef to the last seeded step so the first live tick + // (whose step is >= the seed's last step) doesn't trigger the + // step-regressed reset in the live-append effect below. + const lastSeededStep = points[points.length - 1]?.step ?? 0; + lastStepRef.current = lastSeededStep; + }) + .catch(() => { + // 404 or transient — fall through; live ticks will populate from empty. + }); + return () => { + cancelled = true; + }; + }, [baseUrl, fetchWithHeaders, jobId]); + + // Append new metric points as they arrive; reset when a new run starts + // (current_step resets back to 0). + useEffect(() => { + const step = trainingStatus.current_step; + if (step < lastStepRef.current) { + setLossHistory([]); + setLrHistory([]); + } + lastStepRef.current = step; + + if (step > 0 && trainingStatus.current_loss != null) { + const loss = trainingStatus.current_loss; + setLossHistory((prev) => { + const last = prev[prev.length - 1]; + if (last && last.step === step) return prev; + return [...prev, { step, loss }].slice(-HISTORY_CAP); + }); + } + + if (step > 0 && trainingStatus.current_lr != null) { + const lr = trainingStatus.current_lr; + setLrHistory((prev) => { + const last = prev[prev.length - 1]; + if (last && last.step === step) return prev; + return [...prev, { step, lr }].slice(-HISTORY_CAP); + }); + } + }, [trainingStatus.current_step, trainingStatus.current_loss, trainingStatus.current_lr]); + + const progress = getProgressPercentage(); + // Until tqdm fires its first progress line, total_steps is 0 — show + // "Training starting…" instead of a misleading 0/0 0% reading. + const isStarting = trainingStatus.training_active && trainingStatus.total_steps === 0; + const stepLabel = isStarting + ? 'Training starting…' + : `${trainingStatus.current_step.toLocaleString()} / ${trainingStatus.total_steps.toLocaleString()}`; + const etaLabel = + trainingStatus.eta_seconds != null ? formatTime(trainingStatus.eta_seconds) : '—'; + + return ( +
+ + +
+
+
+ +
+
+

Progress

+
{stepLabel}
+
+
+
+ + + ETA {etaLabel} + +
+
+
+
+
+ {isStarting ? 'warming up…' : `${progress.toFixed(1)}%`} +
+
+ + + +
+ + + +
+ +
+ + Loss{' '} + + ({trainingStatus.current_loss?.toFixed(4) ?? '—'}) + + +
+
+ +
+ {lossHistory.length === 0 ? ( +
+ Waiting for first metric tick… +
+ ) : ( + + + + + v.toFixed(4)} + /> + + + + )} +
+
+
+ + + + +
+ +
+ + Learning Rate{' '} + + ({trainingStatus.current_lr?.toExponential(2) ?? '—'}) + + +
+
+ +
+ {lrHistory.length === 0 ? ( +
+ Waiting for first metric tick… +
+ ) : ( + + + + v.toExponential(0)} + /> + v.toExponential(2)} + /> + + + + )} +
+
+
+
+
+ ); +}; + +export default MonitoringStats; diff --git a/src/components/training/monitoring/TrainingLogs.tsx b/src/components/training/monitoring/TrainingLogs.tsx new file mode 100644 index 0000000000000000000000000000000000000000..39a24548102699afac868628e03d29aae9c4b906 --- /dev/null +++ b/src/components/training/monitoring/TrainingLogs.tsx @@ -0,0 +1,51 @@ + +import React from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { FileText } from 'lucide-react'; +import { LogEntry } from '../types'; + +interface TrainingLogsProps { + logs: LogEntry[]; + logContainerRef: React.RefObject; +} + +const TrainingLogs: React.FC = ({ logs, logContainerRef }) => { + return ( + + + +
+ +
+ Training Logs +
+
+ +
+ {logs.length === 0 ? ( +
+ No training logs yet. Start training to see output. +
+ ) : ( + logs.map((log, index) => ( +
+ + {new Date(log.timestamp * 1000).toLocaleTimeString()} + + {log.message} +
+ )) + )} +
+
+
+ ); +}; + +export default TrainingLogs; diff --git a/src/components/training/types.ts b/src/components/training/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..cd4882ddd31fb91ce961881fb20d10396c613735 --- /dev/null +++ b/src/components/training/types.ts @@ -0,0 +1,83 @@ +export interface TrainingConfig { + target: { runner: "local" | "hf_cloud"; flavor?: string }; + + // Dataset configuration + dataset_repo_id: string; + + // Policy configuration + policy_type: string; + + // Core training parameters + steps: number; + batch_size: number; + seed?: number; + num_workers: number; + + // Logging and checkpointing + log_freq: number; + save_freq: number; + save_checkpoint: boolean; + + // Output configuration + resume: boolean; + + // Weights & Biases + wandb_enable: boolean; + wandb_project?: string; + wandb_entity?: string; + wandb_notes?: string; + wandb_mode?: string; + wandb_disable_artifact: boolean; + + // Policy-specific parameters + policy_device?: string; + policy_use_amp: boolean; + + // Optimizer parameters + optimizer_type?: string; + optimizer_lr?: number; + optimizer_weight_decay?: number; + optimizer_grad_clip_norm?: number; + + // Advanced configuration + use_policy_training_preset: boolean; + + // GR00T-specific configuration (only used when policy_type === "groot"). + dataset_image_transforms_enable?: boolean; + policy_base_model_path?: string; + policy_embodiment_tag?: string; + policy_chunk_size?: number; + policy_n_action_steps?: number; + policy_use_relative_actions?: boolean; + policy_relative_exclude_joints?: string[]; + policy_use_bf16?: boolean; +} + +export interface TrainingStatus { + training_active: boolean; + current_step: number; + total_steps: number; + current_loss?: number; + current_lr?: number; + grad_norm?: number; + epoch_time?: number; + eta_seconds?: number; + available_controls: { + stop_training: boolean; + pause_training: boolean; + resume_training: boolean; + }; +} + +export interface LogEntry { + timestamp: number; + message: string; +} + +export interface ConfigComponentProps { + config: TrainingConfig; + updateConfig: ( + key: T, + value: TrainingConfig[T] + ) => void; +} diff --git a/src/components/ui/PortDetectionButton.tsx b/src/components/ui/PortDetectionButton.tsx new file mode 100644 index 0000000000000000000000000000000000000000..3cc2833c3c94f5f0405a4b6dbf3ac838aad1c166 --- /dev/null +++ b/src/components/ui/PortDetectionButton.tsx @@ -0,0 +1,38 @@ +import React from "react"; +import { Button } from "@/components/ui/button"; +import { Search } from "lucide-react"; + +interface PortDetectionButtonProps { + onClick: () => void; + robotType?: "leader" | "follower"; + className?: string; +} + +const PortDetectionButton: React.FC = ({ + onClick, + robotType, + className = "", +}) => { + return ( + + ); +}; + +export default PortDetectionButton; diff --git a/src/components/ui/PortDetectionModal.tsx b/src/components/ui/PortDetectionModal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a66d541bfa274f619fc97c1a6cb88bed7a23ab60 --- /dev/null +++ b/src/components/ui/PortDetectionModal.tsx @@ -0,0 +1,242 @@ +import React, { useEffect, useRef, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; +import { Loader2, CheckCircle, AlertCircle } from "lucide-react"; +import { useToast } from "@/hooks/use-toast"; +import { useApi } from "@/contexts/ApiContext"; + +interface PortDetectionModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + robotType: "leader" | "follower"; + onPortDetected: (port: string) => void; +} + +const SUCCESS_HOLD_MS = 2000; + +const PortDetectionModal: React.FC = ({ + open, + onOpenChange, + robotType, + onPortDetected, +}) => { + const [step, setStep] = useState<"detecting" | "success" | "error">( + "detecting" + ); + const [detectedPort, setDetectedPort] = useState(""); + const [error, setError] = useState(""); + const cancelledRef = useRef(false); + const abortRef = useRef(null); + const successTimerRef = useRef(null); + const { toast } = useToast(); + const { baseUrl, fetchWithHeaders } = useApi(); + + const runDetection = async () => { + try { + abortRef.current = new AbortController(); + const startResponse = await fetchWithHeaders( + `${baseUrl}/start-port-detection`, + { + method: "POST", + body: JSON.stringify({ robot_type: robotType }), + signal: abortRef.current.signal, + } + ); + const startData = await startResponse.json(); + if (cancelledRef.current) return; + if (startData.status !== "success") { + throw new Error(startData.message || "Failed to start port detection"); + } + const portsBefore: string[] = startData.data.ports_before; + + // Poll the backend in a loop. Each call waits up to 15s for an unplug; + // we silently retry on timeout so the user has unlimited time to read + // and act. The loop ends on success, abort, or a non-timeout failure. + while (!cancelledRef.current) { + abortRef.current = new AbortController(); + const response = await fetchWithHeaders( + `${baseUrl}/detect-port-after-disconnect`, + { + method: "POST", + body: JSON.stringify({ ports_before: portsBefore }), + signal: abortRef.current.signal, + } + ); + const data = await response.json(); + if (cancelledRef.current) return; + + if (data.status === "success") { + setDetectedPort(data.port); + await savePort(data.port); + if (cancelledRef.current) return; + setStep("success"); + toast({ + title: "Port Detected Successfully", + description: `${robotType} port detected: ${data.port}`, + }); + successTimerRef.current = window.setTimeout(() => { + if (cancelledRef.current) return; + onPortDetected(data.port); + onOpenChange(false); + }, SUCCESS_HOLD_MS); + return; + } + + const message = + typeof data.message === "string" ? data.message : ""; + if (message.includes("Timed out")) continue; + throw new Error(message || "Failed to detect port"); + } + } catch (e) { + if (cancelledRef.current) return; + if (e instanceof DOMException && e.name === "AbortError") return; + console.error("Port detection failed:", e); + setError(e instanceof Error ? e.message : "Unknown error"); + setStep("error"); + } + }; + + const savePort = async (port: string) => { + try { + await fetchWithHeaders(`${baseUrl}/save-robot-port`, { + method: "POST", + body: JSON.stringify({ robot_type: robotType, port }), + }); + } catch (e) { + console.error("Error saving port:", e); + } + }; + + useEffect(() => { + if (!open) return; + cancelledRef.current = false; + setStep("detecting"); + setError(""); + setDetectedPort(""); + runDetection(); + return () => { + cancelledRef.current = true; + abortRef.current?.abort(); + if (successTimerRef.current !== null) { + window.clearTimeout(successTimerRef.current); + successTimerRef.current = null; + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + const handleCancel = () => { + onOpenChange(false); + }; + + const handleRetry = () => { + cancelledRef.current = false; + abortRef.current?.abort(); + setStep("detecting"); + setError(""); + setDetectedPort(""); + runDetection(); + }; + + const renderStepContent = () => { + switch (step) { + case "detecting": + return ( +
+ +
+

+ Unplug the {robotType} arm +

+

+ Disconnect the {robotType} robot arm from USB. The port will be + detected automatically. +

+
+
+ +
+
+ ); + + case "success": + return ( +
+ +
+

+ Port Detected +

+

+ {detectedPort} +

+
+
+ ); + + case "error": + return ( +
+ +
+

+ Detection Failed +

+
+

{error}

+
+
+
+ + +
+
+ ); + + default: + return null; + } + }; + + return ( + + + + + Port Detection + + + Detect the USB port for your {robotType} arm + + + +
{renderStepContent()}
+
+
+ ); +}; + +export default PortDetectionModal; diff --git a/src/components/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8722561cf6bda62d62f9a0c67730aefda971873a --- /dev/null +++ b/src/components/ui/alert-dialog.tsx @@ -0,0 +1,139 @@ +import * as React from "react" +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +const AlertDialog = AlertDialogPrimitive.Root + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger + +const AlertDialogPortal = AlertDialogPrimitive.Portal + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)) +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogHeader.displayName = "AlertDialogHeader" + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogFooter.displayName = "AlertDialogFooter" + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/src/components/ui/alert.tsx b/src/components/ui/alert.tsx new file mode 100644 index 0000000000000000000000000000000000000000..41fa7e0561a3fdb5f986c1213a35e563de740e96 --- /dev/null +++ b/src/components/ui/alert.tsx @@ -0,0 +1,59 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)) +Alert.displayName = "Alert" + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertTitle.displayName = "AlertTitle" + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertDescription.displayName = "AlertDescription" + +export { Alert, AlertTitle, AlertDescription } diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f000e3ef5176395b067dfc3f3e1256a80c450015 --- /dev/null +++ b/src/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants } diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx new file mode 100644 index 0000000000000000000000000000000000000000..36496a28727a3643b4212a14225d4f6cbd50bda5 --- /dev/null +++ b/src/components/ui/button.tsx @@ -0,0 +1,56 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground hover:bg-destructive/90", + outline: + "border border-input bg-background hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-10 px-4 py-2", + sm: "h-9 rounded-md px-3", + lg: "h-11 rounded-md px-8", + icon: "h-10 w-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button" + return ( + + ) + } +) +Button.displayName = "Button" + +export { Button, buttonVariants } diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx new file mode 100644 index 0000000000000000000000000000000000000000..afa13ecfa3bd0f4a553a510b856c5800382e139b --- /dev/null +++ b/src/components/ui/card.tsx @@ -0,0 +1,79 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +Card.displayName = "Card" + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardHeader.displayName = "CardHeader" + +const CardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardTitle.displayName = "CardTitle" + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardDescription.displayName = "CardDescription" + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardContent.displayName = "CardContent" + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardFooter.displayName = "CardFooter" + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } diff --git a/src/components/ui/checkbox.tsx b/src/components/ui/checkbox.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ddbdd01d8d1491ab772790db8d40c5ac0a2630f3 --- /dev/null +++ b/src/components/ui/checkbox.tsx @@ -0,0 +1,28 @@ +import * as React from "react" +import * as CheckboxPrimitive from "@radix-ui/react-checkbox" +import { Check } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Checkbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)) +Checkbox.displayName = CheckboxPrimitive.Root.displayName + +export { Checkbox } diff --git a/src/components/ui/collapsible.tsx b/src/components/ui/collapsible.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a23e7a281287e18b1c332498491b6bcc8d8e2b70 --- /dev/null +++ b/src/components/ui/collapsible.tsx @@ -0,0 +1,9 @@ +import * as CollapsiblePrimitive from "@radix-ui/react-collapsible" + +const Collapsible = CollapsiblePrimitive.Root + +const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger + +const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent + +export { Collapsible, CollapsibleTrigger, CollapsibleContent } diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a6047d380c6df25c728da69343e10c4fb9736e75 --- /dev/null +++ b/src/components/ui/command.tsx @@ -0,0 +1,153 @@ +import * as React from "react" +import { type DialogProps } from "@radix-ui/react-dialog" +import { Command as CommandPrimitive } from "cmdk" +import { Search } from "lucide-react" + +import { cn } from "@/lib/utils" +import { Dialog, DialogContent } from "@/components/ui/dialog" + +const Command = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Command.displayName = CommandPrimitive.displayName + +interface CommandDialogProps extends DialogProps {} + +const CommandDialog = ({ children, ...props }: CommandDialogProps) => { + return ( + + + + {children} + + + + ) +} + +const CommandInput = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( +
+ + +
+)) + +CommandInput.displayName = CommandPrimitive.Input.displayName + +const CommandList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandList.displayName = CommandPrimitive.List.displayName + +const CommandEmpty = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>((props, ref) => ( + +)) + +CommandEmpty.displayName = CommandPrimitive.Empty.displayName + +const CommandGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandGroup.displayName = CommandPrimitive.Group.displayName + +const CommandSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +CommandSeparator.displayName = CommandPrimitive.Separator.displayName + +const CommandItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandItem.displayName = CommandPrimitive.Item.displayName + +const CommandShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ) +} +CommandShortcut.displayName = "CommandShortcut" + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +} diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2490decf681d8abb8ef9f528e255bd2cc30f64b4 --- /dev/null +++ b/src/components/ui/dialog.tsx @@ -0,0 +1,124 @@ +import * as React from "react" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { X } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Dialog = DialogPrimitive.Root + +const DialogTrigger = DialogPrimitive.Trigger + +const DialogPortal = DialogPrimitive.Portal + +const DialogClose = DialogPrimitive.Close + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + hideClose?: boolean + } +>(({ className, children, hideClose, ...props }, ref) => ( + + + + {children} + {!hideClose && ( + + + Close + + )} + + +)) +DialogContent.displayName = DialogPrimitive.Content.displayName + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogHeader.displayName = "DialogHeader" + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogFooter.displayName = "DialogFooter" + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogTitle.displayName = DialogPrimitive.Title.displayName + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogDescription.displayName = DialogPrimitive.Description.displayName + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000000000000000000000000000000000000..769ff7aa709f0d7a1afe2a87d180447fc26749e4 --- /dev/null +++ b/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,198 @@ +import * as React from "react" +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" +import { Check, ChevronRight, Circle } from "lucide-react" + +import { cn } from "@/lib/utils" + +const DropdownMenu = DropdownMenuPrimitive.Root + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger + +const DropdownMenuGroup = DropdownMenuPrimitive.Group + +const DropdownMenuPortal = DropdownMenuPrimitive.Portal + +const DropdownMenuSub = DropdownMenuPrimitive.Sub + +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup + +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)) +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)) +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName + +const DropdownMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ) +} +DropdownMenuShortcut.displayName = "DropdownMenuShortcut" + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +} diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx new file mode 100644 index 0000000000000000000000000000000000000000..68551b9276b4164a8263aa58d385db30f81a4453 --- /dev/null +++ b/src/components/ui/input.tsx @@ -0,0 +1,22 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Input = React.forwardRef>( + ({ className, type, ...props }, ref) => { + return ( + + ) + } +) +Input.displayName = "Input" + +export { Input } diff --git a/src/components/ui/label.tsx b/src/components/ui/label.tsx new file mode 100644 index 0000000000000000000000000000000000000000..683faa793819982d64e21cb2939666fd6d4a7b13 --- /dev/null +++ b/src/components/ui/label.tsx @@ -0,0 +1,24 @@ +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const labelVariants = cva( + "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" +) + +const Label = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, ...props }, ref) => ( + +)) +Label.displayName = LabelPrimitive.Root.displayName + +export { Label } diff --git a/src/components/ui/number-input.tsx b/src/components/ui/number-input.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4b4a2b2a16ddd721599f2e704858a378147548c2 --- /dev/null +++ b/src/components/ui/number-input.tsx @@ -0,0 +1,60 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; +import { Input } from "./input"; + +type NumberInputProps = Omit< + React.ComponentProps, + "type" | "value" | "onChange" +> & { + value: number | undefined | null; + onChange: (value: number | undefined) => void; + integer?: boolean; +}; + +const NumberInput = React.forwardRef( + ({ value, onChange, integer = true, className, ...props }, ref) => { + const [display, setDisplay] = React.useState( + value == null ? "" : String(value) + ); + // Track the last value we saw from props so we only resync the + // visible string when the prop changes externally. This lets the + // user clear the field even when the parent keeps the previous + // numeric value (because our onChange(undefined) was ignored). + const lastPropRef = React.useRef(value); + + React.useEffect(() => { + if (value !== lastPropRef.current) { + lastPropRef.current = value; + setDisplay(value == null ? "" : String(value)); + } + }, [value]); + + return ( + { + const next = e.target.value; + setDisplay(next); + if (next === "") { + onChange(undefined); + return; + } + const n = integer ? parseInt(next, 10) : parseFloat(next); + if (Number.isFinite(n)) onChange(n); + }} + className={cn( + "[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:m-0 [&::-webkit-outer-spin-button]:m-0", + className + )} + {...props} + /> + ); + } +); +NumberInput.displayName = "NumberInput"; + +export { NumberInput }; diff --git a/src/components/ui/popover.tsx b/src/components/ui/popover.tsx new file mode 100644 index 0000000000000000000000000000000000000000..bbba7e0ebf26c29526552f2dc4e8e3ecea3d641a --- /dev/null +++ b/src/components/ui/popover.tsx @@ -0,0 +1,29 @@ +import * as React from "react" +import * as PopoverPrimitive from "@radix-ui/react-popover" + +import { cn } from "@/lib/utils" + +const Popover = PopoverPrimitive.Root + +const PopoverTrigger = PopoverPrimitive.Trigger + +const PopoverContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( + + + +)) +PopoverContent.displayName = PopoverPrimitive.Content.displayName + +export { Popover, PopoverTrigger, PopoverContent } diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx new file mode 100644 index 0000000000000000000000000000000000000000..dee7b1c6ee4f276986ef4c4028b1bb3ce549b742 --- /dev/null +++ b/src/components/ui/select.tsx @@ -0,0 +1,158 @@ +import * as React from "react" +import * as SelectPrimitive from "@radix-ui/react-select" +import { Check, ChevronDown, ChevronUp } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Select = SelectPrimitive.Root + +const SelectGroup = SelectPrimitive.Group + +const SelectValue = SelectPrimitive.Value + +const SelectTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + span]:line-clamp-1", + className + )} + {...props} + > + {children} + + + + +)) +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +SelectScrollDownButton.displayName = + SelectPrimitive.ScrollDownButton.displayName + +const SelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = "popper", ...props }, ref) => ( + + + + + {children} + + + + +)) +SelectContent.displayName = SelectPrimitive.Content.displayName + +const SelectLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SelectLabel.displayName = SelectPrimitive.Label.displayName + +const SelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + + {children} + +)) +SelectItem.displayName = SelectPrimitive.Item.displayName + +const SelectSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SelectSeparator.displayName = SelectPrimitive.Separator.displayName + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +} diff --git a/src/components/ui/separator.tsx b/src/components/ui/separator.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6d7f12265ba0338704f013930ce4d52c56527dd1 --- /dev/null +++ b/src/components/ui/separator.tsx @@ -0,0 +1,29 @@ +import * as React from "react" +import * as SeparatorPrimitive from "@radix-ui/react-separator" + +import { cn } from "@/lib/utils" + +const Separator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>( + ( + { className, orientation = "horizontal", decorative = true, ...props }, + ref + ) => ( + + ) +) +Separator.displayName = SeparatorPrimitive.Root.displayName + +export { Separator } diff --git a/src/components/ui/sonner.tsx b/src/components/ui/sonner.tsx new file mode 100644 index 0000000000000000000000000000000000000000..35418149c0656c2257baf517e85496ef759677f5 --- /dev/null +++ b/src/components/ui/sonner.tsx @@ -0,0 +1,29 @@ +import { useTheme } from "next-themes" +import { Toaster as Sonner, toast } from "sonner" + +type ToasterProps = React.ComponentProps + +const Toaster = ({ ...props }: ToasterProps) => { + const { theme = "system" } = useTheme() + + return ( + + ) +} + +export { Toaster, toast } diff --git a/src/components/ui/switch.tsx b/src/components/ui/switch.tsx new file mode 100644 index 0000000000000000000000000000000000000000..aa58baa29c676db7b14a37436a9662107b0111ec --- /dev/null +++ b/src/components/ui/switch.tsx @@ -0,0 +1,27 @@ +import * as React from "react" +import * as SwitchPrimitives from "@radix-ui/react-switch" + +import { cn } from "@/lib/utils" + +const Switch = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +Switch.displayName = SwitchPrimitives.Root.displayName + +export { Switch } diff --git a/src/components/ui/toast.tsx b/src/components/ui/toast.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a822477534192c4df5073e4015f7461e739d3344 --- /dev/null +++ b/src/components/ui/toast.tsx @@ -0,0 +1,127 @@ +import * as React from "react" +import * as ToastPrimitives from "@radix-ui/react-toast" +import { cva, type VariantProps } from "class-variance-authority" +import { X } from "lucide-react" + +import { cn } from "@/lib/utils" + +const ToastProvider = ToastPrimitives.Provider + +const ToastViewport = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastViewport.displayName = ToastPrimitives.Viewport.displayName + +const toastVariants = cva( + "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full", + { + variants: { + variant: { + default: "border bg-background text-foreground", + destructive: + "destructive group border-destructive bg-destructive text-destructive-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Toast = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, variant, ...props }, ref) => { + return ( + + ) +}) +Toast.displayName = ToastPrimitives.Root.displayName + +const ToastAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastAction.displayName = ToastPrimitives.Action.displayName + +const ToastClose = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +ToastClose.displayName = ToastPrimitives.Close.displayName + +const ToastTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastTitle.displayName = ToastPrimitives.Title.displayName + +const ToastDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastDescription.displayName = ToastPrimitives.Description.displayName + +type ToastProps = React.ComponentPropsWithoutRef + +type ToastActionElement = React.ReactElement + +export { + type ToastProps, + type ToastActionElement, + ToastProvider, + ToastViewport, + Toast, + ToastTitle, + ToastDescription, + ToastClose, + ToastAction, +} diff --git a/src/components/ui/toaster.tsx b/src/components/ui/toaster.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6c67edff67a48c6ac1c46f25a6d468dd461e66cb --- /dev/null +++ b/src/components/ui/toaster.tsx @@ -0,0 +1,33 @@ +import { useToast } from "@/hooks/use-toast" +import { + Toast, + ToastClose, + ToastDescription, + ToastProvider, + ToastTitle, + ToastViewport, +} from "@/components/ui/toast" + +export function Toaster() { + const { toasts } = useToast() + + return ( + + {toasts.map(function ({ id, title, description, action, ...props }) { + return ( + +
+ {title && {title}} + {description && ( + {description} + )} +
+ {action} + +
+ ) + })} + +
+ ) +} diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e121f0aea0b32952ae54d7db8965a2c15168b13e --- /dev/null +++ b/src/components/ui/tooltip.tsx @@ -0,0 +1,28 @@ +import * as React from "react" +import * as TooltipPrimitive from "@radix-ui/react-tooltip" + +import { cn } from "@/lib/utils" + +const TooltipProvider = TooltipPrimitive.Provider + +const Tooltip = TooltipPrimitive.Root + +const TooltipTrigger = TooltipPrimitive.Trigger + +const TooltipContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + +)) +TooltipContent.displayName = TooltipPrimitive.Content.displayName + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } diff --git a/src/contexts/ApiContext.tsx b/src/contexts/ApiContext.tsx new file mode 100644 index 0000000000000000000000000000000000000000..80346858cf6b4530f636ffa426ffc8280e44ffd6 --- /dev/null +++ b/src/contexts/ApiContext.tsx @@ -0,0 +1,67 @@ +import React, { createContext, useContext, ReactNode, useState, useCallback, useMemo } from "react"; + +interface ApiContextType { + baseUrl: string; + wsBaseUrl: string; + fetchWithHeaders: (url: string, options?: RequestInit) => Promise; +} + +const ApiContext = createContext(undefined); + +const STORAGE_KEY = "lelab.apiBaseUrl"; +const DEFAULT_LOCALHOST = "http://localhost:8000"; + +const httpToWs = (url: string): string => url.replace(/^http(s?):/, "ws$1:"); + +const resolveInitialBaseUrl = (): string => { + if (typeof window === "undefined") return DEFAULT_LOCALHOST; + + const fromQuery = new URLSearchParams(window.location.search).get("api"); + const stored = window.localStorage.getItem(STORAGE_KEY); + for (const candidate of [fromQuery, stored]) { + if (!candidate) continue; + try { + const url = new URL(candidate); + if (url.protocol !== "http:" && url.protocol !== "https:") continue; + const clean = url.href.replace(/\/$/, ""); + window.localStorage.setItem(STORAGE_KEY, clean); + return clean; + } catch { + console.warn("Invalid API URL, ignoring:", candidate); + } + } + + return DEFAULT_LOCALHOST; +}; + +export const ApiProvider: React.FC<{ children: ReactNode }> = ({ + children, +}) => { + const [baseUrl] = useState(resolveInitialBaseUrl); + const wsBaseUrl = httpToWs(baseUrl); + + const fetchWithHeaders = useCallback(async (url: string, options: RequestInit = {}): Promise => { + return fetch(url, { + ...options, + headers: { + "Content-Type": "application/json", + ...options.headers, + }, + }); + }, []); + + const value = useMemo( + () => ({ baseUrl, wsBaseUrl, fetchWithHeaders }), + [baseUrl, wsBaseUrl, fetchWithHeaders] + ); + + return {children}; +}; + +export const useApi = (): ApiContextType => { + const context = useContext(ApiContext); + if (context === undefined) { + throw new Error("useApi must be used within an ApiProvider"); + } + return context; +}; diff --git a/src/contexts/DragAndDropContext.tsx b/src/contexts/DragAndDropContext.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e47864869be5693c6ed04944a10618f9a794c2e2 --- /dev/null +++ b/src/contexts/DragAndDropContext.tsx @@ -0,0 +1,113 @@ +import React, { + createContext, + useState, + useEffect, + useMemo, + ReactNode, + useCallback, +} from "react"; + +import { processDroppedFiles } from "@/lib/UrdfDragAndDrop"; +import { useUrdf } from "@/hooks/useUrdf"; + +export type DragAndDropContextType = { + isDragging: boolean; + setIsDragging: (isDragging: boolean) => void; + handleDrop: (e: DragEvent) => Promise; +}; + +export const DragAndDropContext = createContext< + DragAndDropContextType | undefined +>(undefined); + +interface DragAndDropProviderProps { + children: ReactNode; +} + +export const DragAndDropProvider: React.FC = ({ + children, +}) => { + const [isDragging, setIsDragging] = useState(false); + + // Get the Urdf context + const { urdfProcessor, processUrdfFiles } = useUrdf(); + + const handleDragOver = (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + }; + + const handleDragEnter = (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(true); + }; + + const handleDragLeave = (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + + // Only set isDragging to false if we're leaving the document + // This prevents flickering when moving between elements + if (!e.relatedTarget || !(e.relatedTarget as Element).closest("html")) { + setIsDragging(false); + } + }; + + const handleDrop = useCallback( + async (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + + if (!e.dataTransfer || !urdfProcessor) return; + + try { + const { availableModels, files } = await processDroppedFiles( + e.dataTransfer, + urdfProcessor + ); + await processUrdfFiles(files, availableModels); + } catch (error) { + console.error("Error in handleDrop:", error); + } + }, + [urdfProcessor, processUrdfFiles] + ); + + // Set up global event listeners + useEffect(() => { + document.addEventListener("dragover", handleDragOver); + document.addEventListener("dragenter", handleDragEnter); + document.addEventListener("dragleave", handleDragLeave); + document.addEventListener("drop", handleDrop); + + return () => { + document.removeEventListener("dragover", handleDragOver); + document.removeEventListener("dragenter", handleDragEnter); + document.removeEventListener("dragleave", handleDragLeave); + document.removeEventListener("drop", handleDrop); + }; + }, [handleDrop]); // Re-register when handleDrop changes + + const value = useMemo( + () => ({ isDragging, setIsDragging, handleDrop }), + [isDragging, handleDrop] + ); + + return ( + + {children} + {isDragging && ( +
+
+
Drop Urdf Files Here
+

+ Release to upload your robot model +

+
+
+ )} +
+ ); +}; diff --git a/src/contexts/HfAuthContext.tsx b/src/contexts/HfAuthContext.tsx new file mode 100644 index 0000000000000000000000000000000000000000..abc9fa0c7f43344829a45cf6b7101f0eec4beb3a --- /dev/null +++ b/src/contexts/HfAuthContext.tsx @@ -0,0 +1,80 @@ +import React, { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + ReactNode, +} from "react"; +import { useApi } from "./ApiContext"; + +export type HfAuthState = + | { status: "loading" } + | { status: "authenticated"; username: string; orgs: string[] } + | { status: "unauthenticated"; loginCommand: string }; + +interface HfAuthValue { + auth: HfAuthState; + refetch: () => Promise; +} + +const HfAuthContext = createContext(undefined); + +export const HfAuthProvider: React.FC<{ children: ReactNode }> = ({ + children, +}) => { + const { baseUrl, fetchWithHeaders } = useApi(); + const [auth, setAuth] = useState({ status: "loading" }); + + const fetchStatus = useCallback(async () => { + setAuth({ status: "loading" }); + try { + const response = await fetchWithHeaders(`${baseUrl}/hf-auth-status`); + const data = await response.json(); + if (data.authenticated) { + setAuth({ + status: "authenticated", + username: data.username, + orgs: data.orgs ?? [], + }); + } else { + setAuth({ + status: "unauthenticated", + loginCommand: data.login_command ?? "hf auth login", + }); + } + } catch (err) { + console.warn("HF auth status fetch failed:", err); + // Drop to a terminal state so consumers waiting on `status !== "loading"` + // don't hang forever when the backend is unreachable. + setAuth({ + status: "unauthenticated", + loginCommand: "hf auth login", + }); + } + }, [baseUrl, fetchWithHeaders]); + + useEffect(() => { + fetchStatus(); + }, [fetchStatus]); + + const value = useMemo( + () => ({ auth, refetch: fetchStatus }), + [auth, fetchStatus] + ); + + return ( + + {children} + + ); +}; + +export const useHfAuth = (): HfAuthValue => { + const ctx = useContext(HfAuthContext); + if (ctx === undefined) { + throw new Error("useHfAuth must be used within an HfAuthProvider"); + } + return ctx; +}; diff --git a/src/contexts/OnboardingContext.tsx b/src/contexts/OnboardingContext.tsx new file mode 100644 index 0000000000000000000000000000000000000000..88baa7a31bb10a86ceace78c46b1a348926a5997 --- /dev/null +++ b/src/contexts/OnboardingContext.tsx @@ -0,0 +1,251 @@ +import React, { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { useLocation, useNavigate } from "react-router-dom"; +import { ONBOARDING_STEPS, TourStep } from "@/lib/onboardingSteps"; +import { useOnboardingProgress } from "@/hooks/useOnboardingProgress"; +import { isHostedSpace } from "@/lib/isHostedSpace"; +import SpotlightOverlay from "@/components/onboarding/SpotlightOverlay"; +import TourLauncher from "@/components/onboarding/TourLauncher"; +import WelcomeDialog from "@/components/onboarding/WelcomeDialog"; + +// Persisted across sessions, mirroring the useUpdateCheck pattern. The `-v1` +// suffix lets a future revamp re-offer the tour to everyone by bumping it. +const STORAGE_KEY = "lelab:onboarding-v1"; +// Delay before auto-advancing once a step's goal is met, so the user sees the +// "done" state register before moving on. +const AUTO_ADVANCE_MS = 1100; + +type OnboardingStatus = "dismissed" | "completed"; + +const readStatus = (): OnboardingStatus | null => { + try { + const raw = localStorage.getItem(STORAGE_KEY); + return raw === "dismissed" || raw === "completed" ? raw : null; + } catch { + return null; + } +}; + +const writeStatus = (status: OnboardingStatus) => { + try { + localStorage.setItem(STORAGE_KEY, status); + } catch { + // Storage may be unavailable (private mode, quota); nothing to persist. + } +}; + +interface OnboardingContextValue { + isActive: boolean; + currentStep: TourStep | null; + /** 0-based index of the current step among the steps visible to this user. */ + stepIndex: number; + totalSteps: number; + /** True when the current step's goal has been achieved. */ + currentComplete: boolean; + /** True when the current step's prerequisite is not met yet (info only). */ + currentGated: boolean; + /** True once the user has finished or dismissed the tour at least once. */ + hasSeen: boolean; + start: () => void; + next: () => void; + back: () => void; + /** Advance past an optional step without performing its action. */ + skipStep: () => void; + /** Leave the tour. `completed` records how it ended for the persisted flag. */ + stop: (completed: boolean) => void; +} + +const OnboardingContext = createContext(null); + +export const OnboardingProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const navigate = useNavigate(); + const location = useLocation(); + + const [isActive, setIsActive] = useState(false); + const [currentStepId, setCurrentStepId] = useState(null); + const [hasSeen, setHasSeen] = useState(() => readStatus() !== null); + const [showWelcome, setShowWelcome] = useState(false); + + const progress = useOnboardingProgress(isActive); + + // Steps whose `show` predicate excludes this user drop out — but the current + // step is always kept so it can never vanish mid-view and strand the tour. + const visibleSteps = useMemo( + () => + ONBOARDING_STEPS.filter( + (s) => s.id === currentStepId || !s.show || s.show(progress) + ), + [progress, currentStepId] + ); + + const stepIndex = currentStepId + ? visibleSteps.findIndex((s) => s.id === currentStepId) + : -1; + const currentStep = + isActive && stepIndex >= 0 ? visibleSteps[stepIndex] : null; + + const currentComplete = currentStep?.isComplete + ? currentStep.isComplete(progress) + : false; + const currentGated = currentStep?.gate ? !currentStep.gate(progress) : false; + + const stop = useCallback((completed: boolean) => { + setIsActive(false); + setCurrentStepId(null); + writeStatus(completed ? "completed" : "dismissed"); + setHasSeen(true); + }, []); + + const start = useCallback(() => { + setShowWelcome(false); + setCurrentStepId(ONBOARDING_STEPS[0]?.id ?? null); + setIsActive(true); + }, []); + + const dismissWelcome = useCallback(() => { + setShowWelcome(false); + writeStatus("dismissed"); + setHasSeen(true); + }, []); + + // Offer the tour once, on the first visit only. Suppressed on the hosted HF + // Space, where UsageInstructionsModal owns the first-run moment (a + // non-dismissible install prompt) — the two must never stack. The corner + // launcher stays available everywhere regardless. + useEffect(() => { + if (readStatus() === null && !isHostedSpace()) { + setShowWelcome(true); + } + }, []); + + const next = useCallback(() => { + const idx = visibleSteps.findIndex((s) => s.id === currentStepId); + if (idx < 0) return; + if (idx >= visibleSteps.length - 1) { + stop(true); + return; + } + setCurrentStepId(visibleSteps[idx + 1].id); + }, [visibleSteps, currentStepId, stop]); + + const back = useCallback(() => { + const idx = visibleSteps.findIndex((s) => s.id === currentStepId); + if (idx > 0) setCurrentStepId(visibleSteps[idx - 1].id); + }, [visibleSteps, currentStepId]); + + const skipStep = next; + + // Navigate to a step's route once, when that step is entered — never during + // render. We deliberately do NOT re-navigate on later location changes, so a + // user who clicks away mid-step isn't yanked back (the spotlight just falls + // back to a centered card). The calibration page needs a robot_name in + // navigation state to show the right robot's controls, so we forward the + // currently-selected robot (persisted by useRobots). + const lastNavStepId = useRef(null); + useEffect(() => { + if (!isActive || !currentStep) { + lastNavStepId.current = null; + return; + } + if (lastNavStepId.current === currentStep.id) return; + lastNavStepId.current = currentStep.id; + if (location.pathname === currentStep.route) return; + if (currentStep.route === "/calibration") { + let robotName: string | null = null; + try { + robotName = localStorage.getItem("lelab.selectedRobot"); + } catch { + // Storage unavailable — fall through to a plain navigation. + } + navigate( + "/calibration", + robotName ? { state: { robot_name: robotName } } : undefined + ); + return; + } + navigate(currentStep.route); + }, [isActive, currentStep, location.pathname, navigate]); + + // Auto-advance when a step's goal is met — but only on a false->true + // transition while the step is shown, so returning to an already-complete + // step via Back doesn't immediately bounce forward again. + const autoRef = useRef<{ id: string | null; done: boolean }>({ + id: null, + done: false, + }); + useEffect(() => { + if (!isActive || !currentStep) return; + if (autoRef.current.id !== currentStep.id) { + // Entering a (possibly already-complete) step: set the baseline and + // never auto-advance out of a step that was already done on arrival. + autoRef.current = { id: currentStep.id, done: currentComplete }; + return; + } + if (!autoRef.current.done && currentComplete) { + autoRef.current.done = true; + const t = setTimeout(() => next(), AUTO_ADVANCE_MS); + return () => clearTimeout(t); + } + }, [isActive, currentStep, currentComplete, next]); + + const value = useMemo( + () => ({ + isActive, + currentStep, + stepIndex, + totalSteps: visibleSteps.length, + currentComplete, + currentGated, + hasSeen, + start, + next, + back, + skipStep, + stop, + }), + [ + isActive, + currentStep, + stepIndex, + visibleSteps.length, + currentComplete, + currentGated, + hasSeen, + start, + next, + back, + skipStep, + stop, + ] + ); + + return ( + + {children} + + + + + ); +}; + +export const useOnboarding = (): OnboardingContextValue => { + const ctx = useContext(OnboardingContext); + if (!ctx) { + throw new Error("useOnboarding must be used within an OnboardingProvider"); + } + return ctx; +}; diff --git a/src/contexts/ThemeContext.tsx b/src/contexts/ThemeContext.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5ed92f83f5603f7b4e7193953285f90d7fbcfecb --- /dev/null +++ b/src/contexts/ThemeContext.tsx @@ -0,0 +1,69 @@ + +import { createContext, ReactNode, useState, useEffect, useCallback, useMemo } from "react"; + +export type Theme = "dark" | "light" | "system"; + +export interface ThemeProviderState { + theme: Theme; + setTheme: (theme: Theme) => void; +} + +const initialState: ThemeProviderState = { + theme: "system", + setTheme: () => null, +}; + +export const ThemeProviderContext = + createContext(initialState); + +interface ThemeProviderProps { + children: ReactNode; + defaultTheme?: Theme; + storageKey?: string; +} + +export function ThemeProvider({ + children, + defaultTheme = "system", + storageKey = "vite-ui-theme", + ...props +}: ThemeProviderProps) { + const [theme, setTheme] = useState( + () => (localStorage.getItem(storageKey) as Theme) || defaultTheme + ); + + useEffect(() => { + const root = window.document.documentElement; + root.classList.remove("light", "dark"); + + if (theme === "system") { + const systemTheme = window.matchMedia("(prefers-color-scheme: dark)") + .matches + ? "dark" + : "light"; + root.classList.add(systemTheme); + return; + } + + root.classList.add(theme); + }, [theme]); + + const updateTheme = useCallback( + (newTheme: Theme) => { + localStorage.setItem(storageKey, newTheme); + setTheme(newTheme); + }, + [storageKey] + ); + + const value = useMemo( + () => ({ theme, setTheme: updateTheme }), + [theme, updateTheme] + ); + + return ( + + {children} + + ); +} diff --git a/src/contexts/UrdfContext.tsx b/src/contexts/UrdfContext.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ed3a0297d5adbca7c05b5d9e72eeb6c561d2eb15 --- /dev/null +++ b/src/contexts/UrdfContext.tsx @@ -0,0 +1,461 @@ +import React, { + createContext, + useState, + useCallback, + useMemo, + ReactNode, + useRef, + useEffect, +} from "react"; +import { toast } from "sonner"; +import { UrdfProcessor, readUrdfFileContent } from "@/lib/UrdfDragAndDrop"; +import { UrdfFileModel } from "@/lib/types"; +import { RobotAnimationConfig } from "@/lib/types"; + +// Define the result interface for Urdf detection +interface UrdfDetectionResult { + hasUrdf: boolean; + modelName?: string; +} + +// Define the context type +export type UrdfContextType = { + urdfProcessor: UrdfProcessor | null; + registerUrdfProcessor: (processor: UrdfProcessor) => void; + onUrdfDetected: ( + callback: (result: UrdfDetectionResult) => void + ) => () => void; + processUrdfFiles: ( + files: Record, + availableModels: string[] + ) => Promise; + urdfBlobUrls: Record; + alternativeUrdfModels: string[]; + isSelectionModalOpen: boolean; + setIsSelectionModalOpen: (isOpen: boolean) => void; + urdfModelOptions: UrdfFileModel[]; + selectUrdfModel: (model: UrdfFileModel) => void; + + isDefaultModel: boolean; + setIsDefaultModel: (isDefault: boolean) => void; + resetToDefaultModel: () => void; + urdfContent: string | null; + + currentAnimationConfig: RobotAnimationConfig | null; + setCurrentAnimationConfig: (config: RobotAnimationConfig | null) => void; +}; + +// Create the context +export const UrdfContext = createContext( + undefined +); + +// Props for the provider component +interface UrdfProviderProps { + children: ReactNode; +} + +export const UrdfProvider: React.FC = ({ children }) => { + // State for Urdf processor + const [urdfProcessor, setUrdfProcessor] = useState( + null + ); + + // State for blob URLs (replacing window.urdfBlobUrls) + const [urdfBlobUrls, setUrdfBlobUrls] = useState>({}); + + // State for alternative models (replacing window.alternativeUrdfModels) + const [alternativeUrdfModels, setAlternativeUrdfModels] = useState( + [] + ); + + // State for the Urdf selection modal + const [isSelectionModalOpen, setIsSelectionModalOpen] = useState(false); + const [urdfModelOptions, setUrdfModelOptions] = useState([]); + + const [isDefaultModel, setIsDefaultModel] = useState(true); + const [urdfContent, setUrdfContent] = useState(null); + + const [currentAnimationConfig, setCurrentAnimationConfig] = + useState(null); + + useEffect(() => { + if (!isDefaultModel || urdfContent) return; + let cancelled = false; + (async () => { + try { + const response = await fetch("/so-101-urdf/urdf/so101_new_calib.urdf"); + if (!response.ok) { + throw new Error(`Failed to fetch default Urdf: ${response.statusText}`); + } + const content = await response.text(); + if (!cancelled) setUrdfContent(content); + } catch (error) { + if (!cancelled) { + console.error("Error loading default Urdf content:", error); + } + } + })(); + return () => { + cancelled = true; + }; + }, [isDefaultModel, urdfContent]); + + // Reference for callbacks + const urdfCallbacksRef = useRef<((result: UrdfDetectionResult) => void)[]>( + [] + ); + + const resetToDefaultModel = useCallback(() => { + setIsDefaultModel(true); + setUrdfContent(null); + setCurrentAnimationConfig(null); + + toast.info("Switched to default model", { + description: "The default ARM100 robot model is now displayed.", + }); + }, []); + + // Register a callback for Urdf detection + const onUrdfDetected = useCallback( + (callback: (result: UrdfDetectionResult) => void) => { + urdfCallbacksRef.current.push(callback); + + return () => { + urdfCallbacksRef.current = urdfCallbacksRef.current.filter( + (cb) => cb !== callback + ); + }; + }, + [] + ); + + // Register a Urdf processor + const registerUrdfProcessor = useCallback((processor: UrdfProcessor) => { + setUrdfProcessor(processor); + }, []); + + const notifyUrdfCallbacks = useCallback( + (result: UrdfDetectionResult) => { + if (result.hasUrdf) { + setIsDefaultModel(false); + } else { + resetToDefaultModel(); + } + urdfCallbacksRef.current.forEach((callback) => callback(result)); + }, + [resetToDefaultModel] + ); + + // Helper function to process the selected Urdf model + const processSelectedUrdf = useCallback( + async (model: UrdfFileModel) => { + if (!urdfProcessor) return; + + // Find the file in our files record + const files = Object.values(urdfBlobUrls) + .filter((url) => url === model.blobUrl) + .map((url) => { + const path = Object.keys(urdfBlobUrls).find( + (key) => urdfBlobUrls[key] === url + ); + return path ? { path, url } : null; + }) + .filter((item) => item !== null); + + if (files.length === 0) { + console.error("❌ Could not find file for selected Urdf model"); + return; + } + + // Show a toast notification that we're loading the Urdf + const loadingToast = toast.loading("Loading Urdf model...", { + description: "Preparing 3D visualization", + duration: 5000, + }); + + try { + // Get the file from our record + const filePath = files[0]?.path; + if (!filePath || !urdfBlobUrls[filePath]) { + throw new Error("File not found in records"); + } + + // Get the actual File object + const response = await fetch(model.blobUrl); + const blob = await response.blob(); + const file = new File( + [blob], + filePath.split("/").pop() || "model.urdf", + { + type: "application/xml", + } + ); + + const urdfContent = await readUrdfFileContent(file); + + setUrdfContent(urdfContent); + + toast.dismiss(loadingToast); + + setIsDefaultModel(false); + + const modelDisplayName = + model.name || model.path.split("/").pop() || "Unknown"; + + toast.success("Urdf model loaded successfully", { + description: `Model: ${modelDisplayName}`, + duration: 3000, + }); + + notifyUrdfCallbacks({ + hasUrdf: true, + modelName: modelDisplayName, + }); + } catch (error) { + // Error case + console.error("❌ Error processing selected Urdf:", error); + toast.dismiss(loadingToast); + toast.error("Error loading Urdf", { + description: `Error: ${ + error instanceof Error ? error.message : String(error) + }`, + duration: 3000, + }); + + // Keep showing the custom model even if loading failed + // No need to reset to default unless user explicitly chooses to + } + }, + [urdfBlobUrls, urdfProcessor, notifyUrdfCallbacks] + ); + + // Function to handle selecting a Urdf model from the modal + const selectUrdfModel = useCallback( + (model: UrdfFileModel) => { + if (!urdfProcessor) { + console.error("❌ No Urdf processor available"); + return; + } + + setIsSelectionModalOpen(false); + + const modelName = + model.name || + model.path + .split("/") + .pop() + ?.replace(/\.urdf$/i, "") || + "Unknown"; + + urdfProcessor.loadUrdf(model.blobUrl); + + setIsDefaultModel(false); + + toast.info(`Loading model: ${modelName}`, { + description: "Preparing 3D visualization", + duration: 2000, + }); + + notifyUrdfCallbacks({ + hasUrdf: true, + modelName, + }); + + processSelectedUrdf(model); + }, + [urdfProcessor, notifyUrdfCallbacks, processSelectedUrdf] + ); + + const processUrdfFiles = useCallback( + async (files: Record, availableModels: string[]) => { + Object.values(urdfBlobUrls).forEach(URL.revokeObjectURL); + setUrdfBlobUrls({}); + setAlternativeUrdfModels([]); + setUrdfModelOptions([]); + + try { + if (availableModels.length > 0 && urdfProcessor) { + const newUrdfBlobUrls: Record = {}; + availableModels.forEach((path) => { + if (files[path]) { + newUrdfBlobUrls[path] = URL.createObjectURL(files[path]); + } + }); + setUrdfBlobUrls(newUrdfBlobUrls); + + setAlternativeUrdfModels(availableModels); + + const modelOptions: UrdfFileModel[] = availableModels.map((path) => { + const fileName = path.split("/").pop() || ""; + const modelName = fileName.replace(/\.urdf$/i, ""); + return { + path, + blobUrl: newUrdfBlobUrls[path], + name: modelName, + }; + }); + + setUrdfModelOptions(modelOptions); + + if (availableModels.length === 1) { + const fileName = availableModels[0].split("/").pop() || ""; + const modelName = fileName.replace(/\.urdf$/i, ""); + + const blobUrl = newUrdfBlobUrls[availableModels[0]]; + if (blobUrl) { + urdfProcessor.loadUrdf(blobUrl); + + setIsDefaultModel(false); + + if (files[availableModels[0]]) { + const loadingToast = toast.loading("Loading Urdf model...", { + description: "Preparing 3D visualization", + duration: 5000, + }); + + try { + const urdfContent = await readUrdfFileContent( + files[availableModels[0]] + ); + + setUrdfContent(urdfContent); + + toast.dismiss(loadingToast); + + toast.success("Urdf model loaded successfully", { + description: `Model: ${modelName}`, + duration: 3000, + }); + + notifyUrdfCallbacks({ + hasUrdf: true, + modelName, + }); + } catch (loadError) { + console.error("Error loading Urdf:", loadError); + toast.dismiss(loadingToast); + toast.error("Error loading Urdf", { + description: `Error: ${ + loadError instanceof Error + ? loadError.message + : String(loadError) + }`, + duration: 3000, + }); + + // Still notify callbacks without detailed data + notifyUrdfCallbacks({ + hasUrdf: true, + modelName, + }); + } + } else { + console.error( + "Could not find file for Urdf model:", + availableModels[0] + ); + + notifyUrdfCallbacks({ + hasUrdf: true, + modelName, + }); + } + } else { + console.warn( + `No blob URL found for ${availableModels[0]}, using path directly` + ); + urdfProcessor.loadUrdf(availableModels[0]); + + setIsDefaultModel(false); + + notifyUrdfCallbacks({ + hasUrdf: true, + modelName, + }); + } + } else { + setIsSelectionModalOpen(true); + + notifyUrdfCallbacks({ + hasUrdf: true, + modelName: "Multiple models available", + }); + } + } else { + notifyUrdfCallbacks({ hasUrdf: false }); + + resetToDefaultModel(); + + toast.error("No Urdf file found", { + description: "Please upload a folder containing a .urdf file.", + duration: 3000, + }); + } + } catch (error) { + console.error("Error processing Urdf files:", error); + toast.error("Error processing files", { + description: `Error: ${ + error instanceof Error ? error.message : String(error) + }`, + duration: 3000, + }); + + resetToDefaultModel(); + } + }, + [notifyUrdfCallbacks, urdfBlobUrls, urdfProcessor, resetToDefaultModel] + ); + + // Revoke blob URLs only on unmount; ref tracks the latest set so we + // don't re-revoke on every state change. + const blobUrlsRef = useRef(urdfBlobUrls); + blobUrlsRef.current = urdfBlobUrls; + useEffect(() => { + return () => { + Object.values(blobUrlsRef.current).forEach(URL.revokeObjectURL); + }; + }, []); + + const contextValue = useMemo( + () => ({ + urdfProcessor, + registerUrdfProcessor, + onUrdfDetected, + processUrdfFiles, + urdfBlobUrls, + alternativeUrdfModels, + isSelectionModalOpen, + setIsSelectionModalOpen, + urdfModelOptions, + selectUrdfModel, + + isDefaultModel, + setIsDefaultModel, + resetToDefaultModel, + urdfContent, + + currentAnimationConfig, + setCurrentAnimationConfig, + }), + [ + urdfProcessor, + registerUrdfProcessor, + onUrdfDetected, + processUrdfFiles, + urdfBlobUrls, + alternativeUrdfModels, + isSelectionModalOpen, + urdfModelOptions, + selectUrdfModel, + isDefaultModel, + resetToDefaultModel, + urdfContent, + currentAnimationConfig, + ] + ); + + return ( + {children} + ); +}; diff --git a/src/hooks/use-toast.ts b/src/hooks/use-toast.ts new file mode 100644 index 0000000000000000000000000000000000000000..2c14125ac48b874740770da80d9e424daf1db58b --- /dev/null +++ b/src/hooks/use-toast.ts @@ -0,0 +1,191 @@ +import * as React from "react" + +import type { + ToastActionElement, + ToastProps, +} from "@/components/ui/toast" + +const TOAST_LIMIT = 1 +const TOAST_REMOVE_DELAY = 1000000 + +type ToasterToast = ToastProps & { + id: string + title?: React.ReactNode + description?: React.ReactNode + action?: ToastActionElement +} + +const actionTypes = { + ADD_TOAST: "ADD_TOAST", + UPDATE_TOAST: "UPDATE_TOAST", + DISMISS_TOAST: "DISMISS_TOAST", + REMOVE_TOAST: "REMOVE_TOAST", +} as const + +let count = 0 + +function genId() { + count = (count + 1) % Number.MAX_SAFE_INTEGER + return count.toString() +} + +type ActionType = typeof actionTypes + +type Action = + | { + type: ActionType["ADD_TOAST"] + toast: ToasterToast + } + | { + type: ActionType["UPDATE_TOAST"] + toast: Partial + } + | { + type: ActionType["DISMISS_TOAST"] + toastId?: ToasterToast["id"] + } + | { + type: ActionType["REMOVE_TOAST"] + toastId?: ToasterToast["id"] + } + +interface State { + toasts: ToasterToast[] +} + +const toastTimeouts = new Map>() + +const addToRemoveQueue = (toastId: string) => { + if (toastTimeouts.has(toastId)) { + return + } + + const timeout = setTimeout(() => { + toastTimeouts.delete(toastId) + dispatch({ + type: "REMOVE_TOAST", + toastId: toastId, + }) + }, TOAST_REMOVE_DELAY) + + toastTimeouts.set(toastId, timeout) +} + +export const reducer = (state: State, action: Action): State => { + switch (action.type) { + case "ADD_TOAST": + return { + ...state, + toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), + } + + case "UPDATE_TOAST": + return { + ...state, + toasts: state.toasts.map((t) => + t.id === action.toast.id ? { ...t, ...action.toast } : t + ), + } + + case "DISMISS_TOAST": { + const { toastId } = action + + // ! Side effects ! - This could be extracted into a dismissToast() action, + // but I'll keep it here for simplicity + if (toastId) { + addToRemoveQueue(toastId) + } else { + state.toasts.forEach((toast) => { + addToRemoveQueue(toast.id) + }) + } + + return { + ...state, + toasts: state.toasts.map((t) => + t.id === toastId || toastId === undefined + ? { + ...t, + open: false, + } + : t + ), + } + } + case "REMOVE_TOAST": + if (action.toastId === undefined) { + return { + ...state, + toasts: [], + } + } + return { + ...state, + toasts: state.toasts.filter((t) => t.id !== action.toastId), + } + } +} + +const listeners: Array<(state: State) => void> = [] + +let memoryState: State = { toasts: [] } + +function dispatch(action: Action) { + memoryState = reducer(memoryState, action) + listeners.forEach((listener) => { + listener(memoryState) + }) +} + +type Toast = Omit + +function toast({ ...props }: Toast) { + const id = genId() + + const update = (props: ToasterToast) => + dispatch({ + type: "UPDATE_TOAST", + toast: { ...props, id }, + }) + const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }) + + dispatch({ + type: "ADD_TOAST", + toast: { + ...props, + id, + open: true, + onOpenChange: (open) => { + if (!open) dismiss() + }, + }, + }) + + return { + id: id, + dismiss, + update, + } +} + +function useToast() { + const [state, setState] = React.useState(memoryState) + + React.useEffect(() => { + listeners.push(setState) + return () => { + const index = listeners.indexOf(setState) + if (index > -1) { + listeners.splice(index, 1) + } + } + }, [state]) + + return { + ...state, + toast, + dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), + } +} + +export { useToast, toast } diff --git a/src/hooks/useAvailableCameras.ts b/src/hooks/useAvailableCameras.ts new file mode 100644 index 0000000000000000000000000000000000000000..dfdff13d9ee7eff11d9912872adee5f6a2c39491 --- /dev/null +++ b/src/hooks/useAvailableCameras.ts @@ -0,0 +1,102 @@ +import { useCallback, useEffect, useState } from "react"; +import { useApi } from "@/contexts/ApiContext"; + +export interface AvailableCamera { + index: number; + name: string; + deviceId: string; + available: boolean; +} + +const norm = (s: string) => s.toLowerCase().replace(/\s+/g, " ").trim(); + +interface UseAvailableCamerasOptions { + /** When false, do nothing. Use to gate on modal open. */ + enabled?: boolean; +} + +/** + * Enumerates cv2 camera indices from `/available-cameras` and merges each + * with the matching browser deviceId (by AVFoundation localizedName) so + * callers can render a preview alongside the bound dropdowns. Refreshes on + * USB hotplug. + */ +export function useAvailableCameras({ + enabled = true, +}: UseAvailableCamerasOptions = {}) { + const { baseUrl, fetchWithHeaders } = useApi(); + const [cameras, setCameras] = useState([]); + const [isLoading, setIsLoading] = useState(false); + + const refresh = useCallback(async (): Promise => { + setIsLoading(true); + try { + // Need a permission grant before enumerateDevices() returns labels. + try { + const probe = await navigator.mediaDevices.getUserMedia({ video: true }); + probe.getTracks().forEach((t) => t.stop()); + } catch { + // ignore — we'll still try to enumerate, just without labels + } + + const browserDevices = (await navigator.mediaDevices.enumerateDevices()) + .filter((d) => d.kind === "videoinput") + .map((d) => ({ deviceId: d.deviceId, label: d.label })); + + const r = await fetchWithHeaders(`${baseUrl}/available-cameras`); + if (!r.ok) { + setCameras([]); + return []; + } + const data = await r.json(); + const backendCams: { + index: number; + name?: string; + available: boolean; + }[] = data.cameras ?? []; + + // Browser's MediaDeviceInfo.label starts with AVFoundation's localizedName + // but Chrome often appends "(vendorId:productId)". Match by exact, then + // prefix, then either-contains. + const used = new Set(); + const merged: AvailableCamera[] = backendCams.map((cam) => { + const label = cam.name || `Camera ${cam.index}`; + const target = norm(label); + const candidates = browserDevices.filter( + (d) => !used.has(d.deviceId) && d.label + ); + const match = + candidates.find((d) => norm(d.label) === target) || + candidates.find((d) => norm(d.label).startsWith(target)) || + candidates.find( + (d) => norm(d.label).includes(target) || target.includes(norm(d.label)) + ); + if (match) used.add(match.deviceId); + return { + index: cam.index, + name: label, + deviceId: match?.deviceId ?? "", + available: cam.available, + }; + }); + setCameras(merged); + return merged; + } catch { + setCameras([]); + return []; + } finally { + setIsLoading(false); + } + }, [baseUrl, fetchWithHeaders]); + + useEffect(() => { + if (!enabled) return; + refresh(); + const handler = () => refresh(); + navigator.mediaDevices.addEventListener("devicechange", handler); + return () => + navigator.mediaDevices.removeEventListener("devicechange", handler); + }, [enabled, refresh]); + + return { cameras, isLoading, refresh }; +} diff --git a/src/hooks/useCameraBindings.ts b/src/hooks/useCameraBindings.ts new file mode 100644 index 0000000000000000000000000000000000000000..74b3e329a63eeaeafa8ffb0e4a40979e6b2b7dce --- /dev/null +++ b/src/hooks/useCameraBindings.ts @@ -0,0 +1,108 @@ +import { useEffect, useState } from "react"; +import { RobotRecord } from "@/hooks/useRobots"; +import { useAvailableCameras } from "@/hooks/useAvailableCameras"; +import { ImageFeatures } from "@/lib/checkpointsApi"; +import { + CameraRequest, + CameraRotation, + liveCameraFor, + PhysicalCamera, + physicalCameraKey, + rotatedDimensions, + rotationForCamera, + serializeCamera, +} from "@/lib/cameraConfig"; + +/** + * Binds each expected camera to a physical one. A robot camera with the same + * name is bound automatically; the user picks the rest. + */ +export function useCameraBindings( + imageFeatures: ImageFeatures | null, + robot: RobotRecord | null, + enabled: boolean, +) { + // Per expected camera name → user-selected physical camera index (or null). + const [bindings, setBindings] = useState>({}); + // User rotation choice per physical camera, over the one saved with the robot. + const [rotations, setRotations] = useState>({}); + const { cameras: availableCameras } = useAvailableCameras({ enabled }); + + // If the selected robot has cameras whose names match an expected camera, + // auto-bind them. Prefer matching by browser device_id (stable across cv2 + // index drift); fall back to the saved camera_index. + useEffect(() => { + if (!imageFeatures) return; + const robotCams = robot?.cameras ?? []; + if (robotCams.length === 0 || availableCameras.length === 0) return; + setBindings((prev) => { + let changed = false; + const next = { ...prev }; + for (const expectedName of Object.keys(imageFeatures)) { + if (next[expectedName] != null) continue; + const robotCam = robotCams.find( + (c) => c.name.toLowerCase() === expectedName.toLowerCase(), + ); + if (!robotCam) continue; + const live = liveCameraFor(robotCam, availableCameras); + if (live) { + next[expectedName] = live.index; + changed = true; + } + } + return changed ? next : prev; + }); + }, [imageFeatures, robot, availableCameras]); + + const allBound = + !!imageFeatures && Object.keys(imageFeatures).every((name) => bindings[name] != null); + + const bind = (name: string, index: number) => + setBindings((prev) => ({ ...prev, [name]: index })); + + useEffect(() => { + if (enabled) setRotations({}); + }, [enabled, robot?.name]); + + const physicalFor = (index: number): PhysicalCamera => + availableCameras.find((c) => c.index === index) ?? { index, deviceId: "" }; + + const rotationFor = (name: string): CameraRotation => { + const idx = bindings[name]; + return idx == null + ? 0 + : rotationForCamera(physicalFor(idx), robot?.cameras ?? [], rotations); + }; + + const setRotation = (name: string, rotation: CameraRotation) => { + const idx = bindings[name]; + if (idx != null) { + setRotations((prev) => ({ ...prev, [physicalCameraKey(physicalFor(idx))]: rotation })); + } + }; + + /** + * The backend camera config: every bound camera. The expected resolution is + * the rotated output, so the API gets the capture dimensions. + */ + const toCameraDict = (fps: number) => { + const cameraDict: Record = {}; + for (const [name, dims] of Object.entries(imageFeatures ?? {})) { + const idx = bindings[name]; + if (idx == null) continue; + const rotation = rotationFor(name); + const capture = rotatedDimensions(dims.width, dims.height, rotation); + cameraDict[name] = serializeCamera({ + type: "opencv", + camera_index: idx, + width: capture.width, + height: capture.height, + rotation, + fps, + }); + } + return cameraDict; + }; + + return { bindings, bind, allBound, availableCameras, rotationFor, setRotation, toCameraDict }; +} diff --git a/src/hooks/useCameraReference.ts b/src/hooks/useCameraReference.ts new file mode 100644 index 0000000000000000000000000000000000000000..8dea95a37497389620afeddc015c3ebaced7ea54 --- /dev/null +++ b/src/hooks/useCameraReference.ts @@ -0,0 +1,68 @@ +import { useCallback, useEffect, useState } from "react"; +import { useApi } from "@/contexts/ApiContext"; +import { + captureVideoFrame, + deleteCameraReference, + getCameraReference, + saveCameraReference, +} from "@/lib/cameraReferenceApi"; + +/** + * The saved reference frame for one robot camera, as an object URL ready to use + * as an ``. Pass `robot = undefined` to disable it. + */ +export function useCameraReference(robot: string | undefined, camera: string) { + const { baseUrl, fetchWithHeaders } = useApi(); + const [url, setUrl] = useState(null); + const [error, setError] = useState(null); + + const show = useCallback((blob: Blob | null) => { + setUrl((prev) => { + if (prev) URL.revokeObjectURL(prev); + return blob ? URL.createObjectURL(blob) : null; + }); + }, []); + + useEffect(() => { + if (!robot) return; + const controller = new AbortController(); + getCameraReference(baseUrl, fetchWithHeaders, robot, camera, controller.signal) + .then(show) + .catch((e) => { + if (!controller.signal.aborted) setError(String(e.message ?? e)); + }); + return () => { + controller.abort(); + show(null); + }; + }, [robot, camera, baseUrl, fetchWithHeaders, show]); + + const save = useCallback( + async (video: HTMLVideoElement) => { + if (!robot) return; + setError(null); + try { + const png = await captureVideoFrame(video); + if (!png) throw new Error("No frame to capture yet"); + await saveCameraReference(baseUrl, fetchWithHeaders, robot, camera, png); + show(png); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } + }, + [robot, camera, baseUrl, fetchWithHeaders, show], + ); + + const clear = useCallback(async () => { + if (!robot) return; + setError(null); + try { + await deleteCameraReference(baseUrl, fetchWithHeaders, robot, camera); + show(null); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } + }, [robot, camera, baseUrl, fetchWithHeaders, show]); + + return { url, error, save, clear }; +} diff --git a/src/hooks/useCameraStream.test.tsx b/src/hooks/useCameraStream.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..eb6c1f328f9b67fc96184d1f5038543ea9544f83 --- /dev/null +++ b/src/hooks/useCameraStream.test.tsx @@ -0,0 +1,61 @@ +// @vitest-environment jsdom +import React, { act } from "react"; +import { createRoot, Root } from "react-dom/client"; +import { afterEach, beforeEach, expect, test, vi } from "vitest"; +import { useCameraStream } from "./useCameraStream"; + +let root: Root; +let container: HTMLDivElement; +const getUserMedia = vi.fn(); +const stop = vi.fn(); + +const Preview = () => { + const { videoRef } = useCameraStream("cam", false, { width: 640, height: 480 }); + return