diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7e706d9 --- /dev/null +++ b/.env.example @@ -0,0 +1,18 @@ +# Firebase Configuration +# Get these values from your Firebase project settings +VITE_FIREBASE_API_KEY=your_firebase_api_key_here +VITE_FIREBASE_AUTH_DOMAIN=your_project.firebaseapp.com +VITE_FIREBASE_PROJECT_ID=your_firebase_project_id +VITE_FIREBASE_STORAGE_BUCKET=your_project.firebasestorage.app +VITE_FIREBASE_MESSAGING_SENDER_ID=your_messaging_sender_id +VITE_FIREBASE_APP_ID=your_firebase_app_id +VITE_FIREBASE_MEASUREMENT_ID=your_measurement_id + +# App Configuration +VITE_API_BASE_URL=https://poselab-backend.nexiotech.cloud +VITE_APP_URL=https://poselab.nexiotech.cloud + +# Sentry error monitoring (optional) +# Install @sentry/react first: npm install @sentry/react +# Get your DSN from https://sentry.io/settings//projects//keys/ +VITE_SENTRY_DSN=your_sentry_dsn_here diff --git a/.firebaserc b/.firebaserc new file mode 100644 index 0000000..0966e86 --- /dev/null +++ b/.firebaserc @@ -0,0 +1,5 @@ +{ + "projects": { + "default": "poselab-584a8" + } +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..48e481e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,103 @@ +name: CI/CD + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + ci: + name: CI + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Cache node_modules + uses: actions/cache@v4 + with: + path: node_modules + key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node- + + - name: Install dependencies + run: npm ci + + - name: Lint + run: npm run lint + + - name: Type check + run: npx tsc --noEmit + + - name: Test + # Only run tests if vitest is configured + if: hashFiles('vitest.config.ts') != '' + run: npm test -- --run + + - name: Build + env: + VITE_FIREBASE_API_KEY: ${{ secrets.VITE_FIREBASE_API_KEY }} + VITE_FIREBASE_AUTH_DOMAIN: ${{ secrets.VITE_FIREBASE_AUTH_DOMAIN }} + VITE_FIREBASE_PROJECT_ID: ${{ secrets.VITE_FIREBASE_PROJECT_ID }} + VITE_FIREBASE_STORAGE_BUCKET: ${{ secrets.VITE_FIREBASE_STORAGE_BUCKET }} + VITE_FIREBASE_MESSAGING_SENDER_ID: ${{ secrets.VITE_FIREBASE_MESSAGING_SENDER_ID }} + VITE_FIREBASE_APP_ID: ${{ secrets.VITE_FIREBASE_APP_ID }} + VITE_FIREBASE_MEASUREMENT_ID: ${{ secrets.VITE_FIREBASE_MEASUREMENT_ID }} + VITE_API_BASE_URL: ${{ secrets.VITE_API_BASE_URL }} + VITE_APP_URL: ${{ secrets.VITE_APP_URL }} + run: npm run build + + deploy: + name: Deploy to Firebase Hosting + runs-on: ubuntu-latest + needs: ci + # Only deploy on pushes to main, not on PRs + if: github.event_name == 'push' + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Cache node_modules + uses: actions/cache@v4 + with: + path: node_modules + key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node- + + - name: Install dependencies + run: npm ci + + - name: Build + env: + VITE_FIREBASE_API_KEY: ${{ secrets.VITE_FIREBASE_API_KEY }} + VITE_FIREBASE_AUTH_DOMAIN: ${{ secrets.VITE_FIREBASE_AUTH_DOMAIN }} + VITE_FIREBASE_PROJECT_ID: ${{ secrets.VITE_FIREBASE_PROJECT_ID }} + VITE_FIREBASE_STORAGE_BUCKET: ${{ secrets.VITE_FIREBASE_STORAGE_BUCKET }} + VITE_FIREBASE_MESSAGING_SENDER_ID: ${{ secrets.VITE_FIREBASE_MESSAGING_SENDER_ID }} + VITE_FIREBASE_APP_ID: ${{ secrets.VITE_FIREBASE_APP_ID }} + VITE_FIREBASE_MEASUREMENT_ID: ${{ secrets.VITE_FIREBASE_MEASUREMENT_ID }} + VITE_API_BASE_URL: ${{ secrets.VITE_API_BASE_URL }} + VITE_APP_URL: ${{ secrets.VITE_APP_URL }} + run: npm run build + + - name: Deploy to Firebase Hosting + uses: FirebaseExtended/action-hosting-deploy@v0 + with: + repoToken: ${{ secrets.GITHUB_TOKEN }} + firebaseServiceAccount: ${{ secrets.FIREBASE_SERVICE_ACCOUNT }} + channelId: live diff --git a/.gitignore b/.gitignore index 6fb2679..d845bad 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,8 @@ node_modules/ -src/firebase.ts \ No newline at end of file +.claude/ +.env +.env.local +.env.*.local +build/ +dist/ +.DS_Store diff --git a/PROJECT_GOALS.md b/PROJECT_GOALS.md new file mode 100644 index 0000000..f26e13f --- /dev/null +++ b/PROJECT_GOALS.md @@ -0,0 +1,239 @@ +# PoseLab — Pre-Launch Development Goals + +> Version: 1.2.0 | Target: Production Launch +> Scope: All goals must be complete before going live. + +--- + +## Goal 1 — Complete Backend API Integration + +**Current State:** Only 5 auth endpoints exist (`/api/auth`, `/api/signup`, `/api/sign-out`, `/api/forgot-password`, `/api/reset-password`). All community feed, gallery, creations, and pose data are mocked on the frontend. + +**What to Build:** +- `POST /api/renders` — save a completed render (image + metadata) +- `GET /api/renders/:userId` — fetch a user's render history +- `GET /api/poses` — list community shared poses with pagination +- `POST /api/poses` — save and publish a pose preset +- `POST /api/posts` — submit a post to the community feed +- `GET /api/feed` — paginated home feed with like/bookmark counts +- `POST /api/feed/:postId/like` — toggle like on a post +- `POST /api/feed/:postId/bookmark` — toggle bookmark on a post +- `GET /api/profile/:userId` — public profile with stats +- `PATCH /api/profile` — update avatar, username, bio + +**Acceptance Criteria:** +- Every page that currently uses mock data is wired to a real API endpoint. +- API responses match Zod schemas defined in the frontend. +- Error states (404, 401, 500) are handled and shown in the UI. + +--- + +## Goal 2 — Secure Secrets & Environment Configuration + +**Current State:** Firebase API keys are hardcoded in `/src/firebase.ts`. No `.env` file strategy is in place. The backend URL is hardcoded as a plain string in `app.config.ts`. + +**What to Build:** +- Move all secrets into `.env` files using Vite's `import.meta.env` pattern. + ``` + VITE_FIREBASE_API_KEY= + VITE_FIREBASE_AUTH_DOMAIN= + VITE_FIREBASE_PROJECT_ID= + VITE_API_BASE_URL= + ``` +- Add `.env.example` with all required keys (no values) committed to the repo. +- Add `.env` and `.env.local` to `.gitignore`. +- Replace all hardcoded keys/URLs in source files with `import.meta.env.*` references. +- Configure CI/CD secrets in the deployment environment. + +**Acceptance Criteria:** +- Zero secrets committed in source code. +- App builds correctly using env vars injected at build time. +- New developer can run the project after copying `.env.example` and filling values. + +--- + +## Goal 3 — Consolidate the Character Rig System + +**Current State:** Three separate, parallel rig implementations exist: +1. `standard/MinecraftCharacter.tsx` — rigid body with basic joints +2. `bendable/BendableMinecraftCharacter.tsx` — flexible skeleton with `SkeletonSystem`, `ArmSystem`, `LegSystem` +3. `rigid-system/NewMinecraftCharacter.tsx` — a third approach with `minecraftRig.ts` + +This triples the maintenance surface, causes inconsistent behavior across pose modes, and confuses the pose-preset format. + +**What to Build:** +- Audit all three rigs: document what each supports (joint count, facial controls, attachment points). +- Pick one as the canonical rig or merge the best features into a single `MinecraftRig` abstraction. +- Migrate all pose presets into a single shared `posePresets.ts` format compatible with the chosen rig. +- Delete the two deprecated rig directories after migration. +- Update `CharacterViewer.tsx` to use only the canonical rig. + +**Acceptance Criteria:** +- One rig system used across the entire app. +- All existing preset poses (standing, running, walking, combat, sitting, waving) work correctly. +- Facial expression controls and attachment points remain functional. + +--- + +## Goal 4 — Implement Real Render Persistence & Gallery + +**Current State:** The render export pipeline in `renderUtils.ts` correctly generates PNG output and downloads it locally. However, renders are never saved to a backend or linked to the user's account. `myCreations/` shows a mock gallery. + +**What to Build:** +- After a successful render, upload the PNG to cloud storage (Firebase Storage or equivalent). +- Save render metadata (image URL, pose snapshot, skin URL, timestamp, render settings) via `POST /api/renders`. +- Wire `myCreations/` to `GET /api/renders/:userId` with real data. +- Support filtering by date and render type in the gallery. +- Add delete-render capability (`DELETE /api/renders/:renderId`). +- Show a per-render detail view with the pose settings used. + +**Acceptance Criteria:** +- A render survives a page refresh and is visible in My Creations. +- A user logging in on a second device sees their full render history. +- Deleting a render removes it from both the UI and storage. + +--- + +## Goal 5 — Complete Creator Space + +**Current State:** The `/creatorSpace` route shows a `` placeholder. + +**What to Build:** +- **Creator Dashboard:** stats on their published poses (views, saves, likes) and renders. +- **Pose Publishing:** a flow to take a current in-editor pose, add a name/description/tags, and publish it to the community pose library. +- **Creator Profile Badge:** display "Creator" badge on profile once a pose is published. +- **Creator Application Review:** wire the existing creator application form (`/account/creatorApplication`) to a backend endpoint so submissions are stored and reviewable. +- **Pose Management:** list of their published poses with edit/unpublish controls. + +**Acceptance Criteria:** +- A user can publish a pose preset from the editor and see it appear in the Creator Space. +- Stats update when other users save or like their poses. +- Creator application submissions are persisted to the backend. + +--- + +## Goal 6 — Add a Test Suite + +**Current State:** Zero test files exist. No Jest, Vitest, or Testing Library setup. + +**What to Build:** +- Install and configure **Vitest** + **React Testing Library** (aligns with Vite). +- Unit tests (target: all utility functions): + - `renderUtils.ts` — resolution calculation, quality preset application + - `pbrMaterials.ts` — material creation + - `posePresets.ts` — preset shape validation + - Auth store (`authStore.ts`) — sign-in, sign-out, token refresh logic +- Component tests: + - `PoseControls` — slider updates propagate to pose state + - `RenderDialog` — size/quality selection updates settings correctly + - Auth forms — validation errors appear on invalid input +- Integration tests: + - Sign-in flow (mocked API): error state, success redirect + - Render export: mock canvas, verify download is triggered + +**Acceptance Criteria:** +- `npm test` runs the suite without errors. +- Core utility functions have ≥80% line coverage. +- CI pipeline runs tests on every push and fails the build on test failure. + +--- + +## Goal 7 — Performance Optimization for 3D Viewport + +**Current State:** The `CharacterViewer` loads all three rig systems eagerly, applies post-processing effects (Bloom, SSAO, Depth of Field) unconditionally, and `useDeviceQuality` adjusts quality after mount—causing a visible quality drop on load. + +**What to Build:** +- **Lazy-load rig components:** use `React.lazy` + `Suspense` for `BendableMinecraftCharacter` and `NewMinecraftCharacter`; only load the active rig. +- **Pre-mount quality detection:** run device quality detection before the scene mounts so the correct settings are applied from frame one. +- **Post-processing gating:** disable SSAO and Depth of Field on low/medium quality profiles; only enable on high/ultra. +- **Texture atlas caching:** cache the Minecraft skin canvas texture so re-uploads on the same skin are instant. +- **Shadow map optimization:** use `CascadedShadowMap` for better quality at lower cost on supported devices. +- Measure: target ≥60 fps on mid-range hardware (integrated GPU). + +**Acceptance Criteria:** +- No quality-drop flash on initial load. +- Low/medium quality profiles maintain ≥60 fps on integrated GPU (tested in Chrome DevTools CPU throttle 4×). +- Switching between rig types does not cause a full scene remount. + +--- + +## Goal 8 — Mobile Experience & Touch Controls + +**Current State:** The UI is responsive at a CSS level, but the 3D pose editor is unusable on mobile: orbit controls require mouse drag, pose sliders stack in a way that pushes the viewport off screen, and the render dialog is not scrollable on small screens. + +**What to Build:** +- **Touch orbit controls:** enable `OrbitControls` touch events (pinch-to-zoom, one-finger rotate, two-finger pan) in `CharacterViewer`. +- **Collapsible control panels:** on screens <768px, all sidebar panels (Pose, Lighting, Quality) default to collapsed; expand on tap. +- **Bottom sheet pose controls:** on mobile, open pose sliders in a swipeable bottom sheet instead of a sidebar. +- **Responsive render dialog:** make `RenderDialog` scrollable and limit default size presets to max device resolution. +- **Viewport height fix:** use `dvh` units (dynamic viewport height) to prevent the control panel from being clipped by mobile browser chrome. + +**Acceptance Criteria:** +- Full pose editing and render export flow is completable on a 390px-wide viewport (iPhone 14 Pro). +- Pinch-to-zoom and rotate work correctly in the 3D viewport on a touch device. +- No layout overflow or hidden controls on mobile. + +--- + +## Goal 9 — Community Feed with Real-Time Data & Moderation + +**Current State:** The Home feed is fully mocked (static post data). Stories, trending challenges, and post interactions (like, bookmark, follow) fire no API calls. There is no content moderation layer. + +**What to Build:** +- Wire all feed interactions to real API endpoints (like, bookmark, follow, unfollow). +- Implement infinite scroll pagination for the feed (`GET /api/feed?page=&limit=`). +- **Stories:** connect story carousel to real user renders (`GET /api/stories`). +- **Trending challenges:** pull from `GET /api/challenges` with view/submission counts. +- **Basic moderation:** add a "Report" option on posts and renders; store reports via `POST /api/reports`. Flag reported content for admin review. +- **Notifications:** real-time badge on new likes/follows using Firebase Realtime Database or WebSocket. + +**Acceptance Criteria:** +- Feed loads real posts and updates on interaction without page refresh. +- Infinite scroll loads the next page when the user reaches the bottom. +- A reported post is flagged in the backend and the reporter sees a confirmation. + +--- + +## Goal 10 — CI/CD Pipeline & Production Readiness + +**Current State:** No CI/CD configuration exists in the repository. Deployment process, environment promotion, and release strategy are undefined. No error monitoring or analytics beyond Firebase Analytics. + +**What to Build:** +- **GitHub Actions workflow** (`.github/workflows/ci.yml`): + - Trigger: every push and PR to `main`. + - Steps: install → lint (ESLint) → type-check (tsc --noEmit) → test (Vitest) → build. + - Block merges to `main` if any step fails. +- **Deployment workflow** (`.github/workflows/deploy.yml`): + - Trigger: push to `main` (after CI passes). + - Build Vite production bundle with injected env vars. + - Deploy to hosting (Firebase Hosting, Vercel, or equivalent). +- **Error monitoring:** integrate **Sentry** for frontend error tracking. Capture unhandled exceptions and failed renders with user context. +- **Structured logging:** add a centralized error boundary in `App.tsx` that reports to Sentry and shows a user-friendly fallback UI. +- **Health check:** expose a `/health` endpoint on the backend that CI can ping post-deploy to verify the deployment succeeded. + +**Acceptance Criteria:** +- Every pull request to `main` runs linting, type-check, and tests automatically. +- Merging to `main` triggers an automatic deployment. +- Sentry captures and groups frontend errors in production. +- Build time stays under 60 seconds. + +--- + +## Priority Order + +| # | Goal | Priority | Effort | +|---|------|----------|--------| +| 2 | Secure Secrets & Env Config | Critical | Small | +| 1 | Complete Backend API Integration | Critical | Large | +| 4 | Render Persistence & Gallery | High | Medium | +| 3 | Consolidate Character Rig System | High | Medium | +| 10 | CI/CD Pipeline & Production Readiness | High | Small | +| 6 | Add a Test Suite | High | Medium | +| 7 | Performance Optimization | Medium | Medium | +| 8 | Mobile Experience & Touch Controls | Medium | Medium | +| 5 | Complete Creator Space | Medium | Large | +| 9 | Community Feed with Real-Time Data | Medium | Large | + +--- + +*Goals 2 and 10 are prerequisites for all others — secure config must be in place before any real data flows, and CI must be running before feature development scales up.* diff --git a/firebase.json b/firebase.json new file mode 100644 index 0000000..e7c0a50 --- /dev/null +++ b/firebase.json @@ -0,0 +1,7 @@ +{ + "hosting": { + "public": "build", + "ignore": ["firebase.json", "**/.*", "**/node_modules/**"], + "rewrites": [{"source": "**", "destination": "/index.html"}] + } +} diff --git a/package-lock.json b/package-lock.json index 64a39f4..23c4453 100644 --- a/package-lock.json +++ b/package-lock.json @@ -81,6 +81,9 @@ "@eslint/js": "^9.17.0", "@tailwindcss/postcss": "^4.0.8", "@tailwindcss/typography": "^0.5.15", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/d3-fetch": "^3.0.7", "@types/d3-scale": "^4.0.8", "@types/js-cookie": "^3.0.6", @@ -96,6 +99,7 @@ "@typescript-eslint/eslint-plugin": "^8.5.0", "@typescript-eslint/parser": "^8.5.0", "@vitejs/plugin-react": "^4.3.4", + "@vitest/coverage-v8": "^4.1.9", "color": "^4.2.3", "eslint": "^9.17.0", "eslint-config-prettier": "^9.1.0", @@ -106,6 +110,7 @@ "eslint-plugin-react": "^7.37.3", "eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-refresh": "^0.4.14", + "jsdom": "^29.1.1", "postcss": "^8.4.45", "postcss-nesting": "^13.0.0", "prettier": "^3.3.3", @@ -113,7 +118,8 @@ "typescript": "^5.7.2", "typescript-eslint": "^8.18.2", "vite": "^6.0.5", - "vite-plugin-dynamic-import": "^1.5.0" + "vite-plugin-dynamic-import": "^1.5.0", + "vitest": "^4.1.9" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -125,6 +131,13 @@ "node": ">=0.10.0" } }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "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", @@ -151,6 +164,57 @@ "node": ">=6.0.0" } }, + "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": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "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/code-frame": { "version": "7.26.2", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", @@ -301,18 +365,18 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", - "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", - "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -343,12 +407,12 @@ } }, "node_modules/@babel/parser": { - "version": "7.26.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.3.tgz", - "integrity": "sha512-WJ/CvmY8Mea8iDXo6a7RK2wbmJITT5fN3BEkRuFlxVyNx8jOKIIhmC4fSkTcPcf8JyavbBwIe6OpiCOBXt/IcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "license": "MIT", "dependencies": { - "@babel/types": "^7.26.3" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -431,18 +495,181 @@ } }, "node_modules/@babel/types": { - "version": "7.26.3", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.3.tgz", - "integrity": "sha512-vN5p+1kl59GVKMvTHt55NzzmYVxprfJD+ql7U9NFIfKCBkYE55LYtS+WtPlaYOyzydrKI8Nezd+aZextrd+FMA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.25.9", - "@babel/helper-validator-identifier": "^7.25.9" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "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.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "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.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "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.1.7", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.7.tgz", + "integrity": "sha512-CmjJFQTFQx/U/xNJhSjCQ0ilpesPmNQ8+eOUeM/+kDOVW33qsIjeOXc27vrQDdWVkf83ZSWwtg7kXSUvKDJ8cQ==", + "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.0.2", + "@csstools/css-calc": "^3.2.1" + }, + "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.5", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.5.tgz", + "integrity": "sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A==", + "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/@csstools/selector-resolve-nested": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@csstools/selector-resolve-nested/-/selector-resolve-nested-2.0.0.tgz", @@ -1174,6 +1401,24 @@ "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/@firebase/ai": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/@firebase/ai/-/ai-1.4.1.tgz", @@ -2018,14 +2263,16 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.4.15", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", - "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==" + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "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" @@ -3425,6 +3672,13 @@ "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", "dev": true }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@svgdotjs/svg.draggable.js": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@svgdotjs/svg.draggable.js/-/svg.draggable.js-3.0.4.tgz", @@ -3834,6 +4088,107 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@tiptap/core": { "version": "2.10.4", "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.10.4.tgz", @@ -4225,6 +4580,14 @@ "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", "license": "MIT" }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -4266,6 +4629,17 @@ "@babel/types": "^7.20.7" } }, + "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-dsv": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", @@ -4304,6 +4678,13 @@ "@types/ms": "*" } }, + "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", @@ -4796,20 +5177,137 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0" } }, - "node_modules/@webgpu/types": { - "version": "0.1.68", - "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.68.tgz", - "integrity": "sha512-3ab1B59Ojb6RwjOspYLsTpCzbNB3ZaamIAxBMmvnNkiDoLTZUOBXZ9p5nAYVEkQlDdf6qAZWi1pqj9+ypiqznA==", - "license": "BSD-3-Clause" - }, - "node_modules/@yr/monotone-cubic-spline": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@yr/monotone-cubic-spline/-/monotone-cubic-spline-1.0.3.tgz", - "integrity": "sha512-FQXkOta0XBSUPHndIKON2Y9JeQz5ZeMqLYZVVK93FliNBFm7LNMIZmY6FrMEB9XPcDbE2bekMbZD6kzDkxwYjA==", + "node_modules/@vitest/coverage-v8": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.9.tgz", + "integrity": "sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==", + "dev": true, "license": "MIT", - "peer": true - }, - "node_modules/acorn": { + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.9", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.9", + "vitest": "4.1.9" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.9", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@webgpu/types": { + "version": "0.1.68", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.68.tgz", + "integrity": "sha512-3ab1B59Ojb6RwjOspYLsTpCzbNB3ZaamIAxBMmvnNkiDoLTZUOBXZ9p5nAYVEkQlDdf6qAZWi1pqj9+ypiqznA==", + "license": "BSD-3-Clause" + }, + "node_modules/@yr/monotone-cubic-spline": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@yr/monotone-cubic-spline/-/monotone-cubic-spline-1.0.3.tgz", + "integrity": "sha512-FQXkOta0XBSUPHndIKON2Y9JeQz5ZeMqLYZVVK93FliNBFm7LNMIZmY6FrMEB9XPcDbE2bekMbZD6kzDkxwYjA==", + "license": "MIT", + "peer": true + }, + "node_modules/acorn": { "version": "8.14.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", @@ -5069,12 +5567,41 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types-flow": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", "dev": true }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.4.tgz", + "integrity": "sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -5373,6 +5900,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "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/character-entities": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-1.2.4.tgz", @@ -5583,6 +6120,27 @@ "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/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -5724,6 +6282,20 @@ "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", "dev": true }, + "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/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -5799,6 +6371,13 @@ } } }, + "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/decode-named-character-reference": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.0.2.tgz", @@ -5948,6 +6527,14 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dom-helpers": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", @@ -6777,27 +7364,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/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, - "engines": { - "node": ">=8" - } - }, - "node_modules/eslint/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, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/espree": { "version": "10.3.0", "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz", @@ -6869,6 +7435,16 @@ "url": "https://opencollective.com/unified" } }, + "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", @@ -6883,6 +7459,16 @@ "resolved": "https://registry.npmjs.org/exenv/-/exenv-1.2.2.tgz", "integrity": "sha512-Z+ktTxTwv9ILfgKCk32OX3n/doe+OcLTRtqK9pcL+JsP3J1/VW8Uvl4ZjLlKqeW4rzK4oesDOGMEMRIZqtP4Iw==" }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -7383,6 +7969,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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/has-property-descriptors": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", @@ -7599,6 +8195,26 @@ "htmlparser2": "10.0.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/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/html-parse-stringify": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", @@ -7769,6 +8385,16 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/inline-style-parser": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.4.tgz", @@ -8129,6 +8755,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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", @@ -8288,6 +8921,45 @@ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/iterator.prototype": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.4.tgz", @@ -8363,6 +9035,57 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -8830,6 +9553,17 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/maath": { "version": "0.10.8", "resolved": "https://registry.npmjs.org/maath/-/maath-0.10.8.tgz", @@ -8841,15 +9575,41 @@ } }, "node_modules/magic-string": { - "version": "0.30.5", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.5.tgz", - "integrity": "sha512-7xlpfBaQaP/T6Vh8MO/EqXSW5En6INHEvEXQiuff7Gku0PWjU3uf6w/j9o7O+SpB5fOAkrI5HeoNgwjEO0pFsA==", + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", "dev": true, + "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.15" + "semver": "^7.5.3" }, "engines": { - "node": ">=12" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/markdown-it": { @@ -9185,6 +9945,13 @@ "url": "https://opencollective.com/unified" } }, + "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/mdurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", @@ -9673,10 +10440,20 @@ "node": ">= 0.6" } }, - "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, "dependencies": { "brace-expansion": "^1.1.7" @@ -9885,6 +10662,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "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.3", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", @@ -9982,6 +10773,32 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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/parse5/node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -10012,6 +10829,13 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -10147,6 +10971,44 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/prismjs": { "version": "1.29.0", "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.29.0.tgz", @@ -10801,6 +11663,20 @@ } } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.9.tgz", @@ -11105,6 +11981,19 @@ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, + "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.25.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz", @@ -11250,6 +12139,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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/simple-swizzle": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", @@ -11323,6 +12219,13 @@ "url": "https://github.com/sponsors/wooorm" } }, + "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", @@ -11349,6 +12252,13 @@ "integrity": "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw==", "license": "MIT" }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz", @@ -11524,6 +12434,19 @@ "node": ">=4" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -11559,6 +12482,19 @@ "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==" }, + "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", @@ -11592,6 +12528,13 @@ "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.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/tabbable": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", @@ -11669,6 +12612,81 @@ "integrity": "sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tippy.js": { "version": "6.3.7", "resolved": "https://registry.npmjs.org/tippy.js/-/tippy.js-6.3.7.tgz", @@ -11678,6 +12696,26 @@ "@popperjs/core": "^2.9.0" } }, + "node_modules/tldts": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.3.tgz", + "integrity": "sha512-A3BDQBeeukYPzB4QdQ1DtdlUmp4x2OCH8n5UVhEWbyANxNep8GavottKzd1xYKFJKjUgMyPT7EzOfnBO55s8Sg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.3" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.3.tgz", + "integrity": "sha512-27ep5H9PzdBrNd5OFM/j3WCU8F3kPwM9D0BOaOf7uYfxMJfyr0K5Tjj69Gri+sZlh2WXd5buIm47NuPF29CDiw==", + "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", @@ -11690,6 +12728,32 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "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/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -11968,6 +13032,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "6.19.8", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", @@ -12344,6 +13418,143 @@ "magic-string": "^0.30.1" } }, + "node_modules/vitest": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/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/@vitest/mocker": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vitest/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/void-elements": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", @@ -12357,6 +13568,19 @@ "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==" }, + "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/warning": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", @@ -12382,6 +13606,16 @@ "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/websocket-driver": { "version": "0.7.4", "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", @@ -12405,6 +13639,31 @@ "node": ">=0.8.0" } }, + "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", @@ -12506,6 +13765,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -12523,6 +13799,23 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "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/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -12668,6 +13961,12 @@ "integrity": "sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==", "dev": true }, + "@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true + }, "@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -12684,6 +13983,44 @@ "@jridgewell/trace-mapping": "^0.3.24" } }, + "@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, + "requires": { + "@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" + } + }, + "@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "requires": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + } + }, + "@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 + }, + "@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 + }, "@babel/code-frame": { "version": "7.26.2", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", @@ -12791,14 +14128,14 @@ "dev": true }, "@babel/helper-string-parser": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", - "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==" + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==" }, "@babel/helper-validator-identifier": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", - "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==" + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==" }, "@babel/helper-validator-option": { "version": "7.25.9", @@ -12817,11 +14154,11 @@ } }, "@babel/parser": { - "version": "7.26.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.3.tgz", - "integrity": "sha512-WJ/CvmY8Mea8iDXo6a7RK2wbmJITT5fN3BEkRuFlxVyNx8jOKIIhmC4fSkTcPcf8JyavbBwIe6OpiCOBXt/IcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "requires": { - "@babel/types": "^7.26.3" + "@babel/types": "^7.29.7" } }, "@babel/plugin-transform-react-jsx-self": { @@ -12872,14 +14209,72 @@ } }, "@babel/types": { - "version": "7.26.3", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.3.tgz", - "integrity": "sha512-vN5p+1kl59GVKMvTHt55NzzmYVxprfJD+ql7U9NFIfKCBkYE55LYtS+WtPlaYOyzydrKI8Nezd+aZextrd+FMA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "requires": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + } + }, + "@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true + }, + "@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, + "requires": { + "css-tree": "^3.0.0" + } + }, + "@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true + }, + "@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "dev": true, + "requires": {} + }, + "@csstools/css-color-parser": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.7.tgz", + "integrity": "sha512-CmjJFQTFQx/U/xNJhSjCQ0ilpesPmNQ8+eOUeM/+kDOVW33qsIjeOXc27vrQDdWVkf83ZSWwtg7kXSUvKDJ8cQ==", + "dev": true, "requires": { - "@babel/helper-string-parser": "^7.25.9", - "@babel/helper-validator-identifier": "^7.25.9" + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.2.1" } }, + "@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, + "requires": {} + }, + "@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.5.tgz", + "integrity": "sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A==", + "dev": true, + "requires": {} + }, + "@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 + }, "@csstools/selector-resolve-nested": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@csstools/selector-resolve-nested/-/selector-resolve-nested-2.0.0.tgz", @@ -13278,6 +14673,13 @@ "levn": "^0.4.1" } }, + "@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, + "requires": {} + }, "@firebase/ai": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/@firebase/ai/-/ai-1.4.1.tgz", @@ -13876,14 +15278,14 @@ "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==" }, "@jridgewell/sourcemap-codec": { - "version": "1.4.15", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", - "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==" + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==" }, "@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "requires": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" @@ -14599,6 +16001,12 @@ "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", "dev": true }, + "@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true + }, "@svgdotjs/svg.draggable.js": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@svgdotjs/svg.draggable.js/-/svg.draggable.js-3.0.4.tgz", @@ -14822,6 +16230,73 @@ "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.11.2.tgz", "integrity": "sha512-vTtpNt7mKCiZ1pwU9hfKPhpdVO2sVzFQsxoVBGtOSHxlrRRzYr8iQ2TlwbAcRYCcEiZ9ECAM8kBzH0v2+VzfKw==" }, + "@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "peer": true, + "requires": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "dependencies": { + "aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "peer": true, + "requires": { + "dequal": "^2.0.3" + } + } + } + }, + "@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "requires": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "dependencies": { + "dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true + } + } + }, + "@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "requires": { + "@babel/runtime": "^7.12.5" + } + }, + "@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "requires": {} + }, "@tiptap/core": { "version": "2.10.4", "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.10.4.tgz", @@ -15028,6 +16503,13 @@ "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==" }, + "@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "peer": true + }, "@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -15069,6 +16551,16 @@ "@babel/types": "^7.20.7" } }, + "@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, + "requires": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "@types/d3-dsv": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", @@ -15107,6 +16599,12 @@ "@types/ms": "*" } }, + "@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 + }, "@types/draco3d": { "version": "1.4.10", "resolved": "https://registry.npmjs.org/@types/draco3d/-/draco3d-1.4.10.tgz", @@ -15458,17 +16956,97 @@ "prop-types": "^15.5.10" } }, - "@vitejs/plugin-react": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.3.4.tgz", - "integrity": "sha512-SCCPBJtYLdE8PX/7ZQAs1QAZ8Jqwih+0VBLum1EGqmCCQal+MIUqLCzj3ZUy8ufbC0cAM4LRlSTm7IQJwWT4ug==", + "@vitejs/plugin-react": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.3.4.tgz", + "integrity": "sha512-SCCPBJtYLdE8PX/7ZQAs1QAZ8Jqwih+0VBLum1EGqmCCQal+MIUqLCzj3ZUy8ufbC0cAM4LRlSTm7IQJwWT4ug==", + "dev": true, + "requires": { + "@babel/core": "^7.26.0", + "@babel/plugin-transform-react-jsx-self": "^7.25.9", + "@babel/plugin-transform-react-jsx-source": "^7.25.9", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.14.2" + } + }, + "@vitest/coverage-v8": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.9.tgz", + "integrity": "sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==", + "dev": true, + "requires": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.9", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + } + }, + "@vitest/expect": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", + "dev": true, + "requires": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + } + }, + "@vitest/pretty-format": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", + "dev": true, + "requires": { + "tinyrainbow": "^3.1.0" + } + }, + "@vitest/runner": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", + "dev": true, + "requires": { + "@vitest/utils": "4.1.9", + "pathe": "^2.0.3" + } + }, + "@vitest/snapshot": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", + "dev": true, + "requires": { + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + } + }, + "@vitest/spy": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", + "dev": true + }, + "@vitest/utils": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", "dev": true, "requires": { - "@babel/core": "^7.26.0", - "@babel/plugin-transform-react-jsx-self": "^7.25.9", - "@babel/plugin-transform-react-jsx-source": "^7.25.9", - "@types/babel__core": "^7.20.5", - "react-refresh": "^0.14.2" + "@vitest/pretty-format": "4.1.9", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" } }, "@webgpu/types": { @@ -15665,12 +17243,37 @@ "is-array-buffer": "^3.0.4" } }, + "assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true + }, "ast-types-flow": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", "dev": true }, + "ast-v8-to-istanbul": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.4.tgz", + "integrity": "sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==", + "dev": true, + "requires": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + }, + "dependencies": { + "js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true + } + } + }, "asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -15844,6 +17447,12 @@ "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==" }, + "chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true + }, "character-entities": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-1.2.4.tgz", @@ -15992,6 +17601,22 @@ "which": "^2.0.1" } }, + "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, + "requires": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + } + }, + "css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true + }, "cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -16088,6 +17713,16 @@ "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", "dev": true }, + "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, + "requires": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + } + }, "data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -16134,6 +17769,12 @@ "ms": "2.1.2" } }, + "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 + }, "decode-named-character-reference": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.0.2.tgz", @@ -16240,6 +17881,13 @@ "dequal": "^2.0.0" } }, + "dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "peer": true + }, "dom-helpers": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", @@ -16593,21 +18241,6 @@ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", "dev": true - }, - "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 - }, - "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, - "requires": { - "has-flag": "^4.0.0" - } } } }, @@ -16917,6 +18550,15 @@ "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==" }, + "estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "requires": { + "@types/estree": "^1.0.0" + } + }, "esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -16928,6 +18570,12 @@ "resolved": "https://registry.npmjs.org/exenv/-/exenv-1.2.2.tgz", "integrity": "sha512-Z+ktTxTwv9ILfgKCk32OX3n/doe+OcLTRtqK9pcL+JsP3J1/VW8Uvl4ZjLlKqeW4rzK4oesDOGMEMRIZqtP4Iw==" }, + "expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true + }, "extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -17279,6 +18927,12 @@ "integrity": "sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==", "dev": true }, + "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 + }, "has-property-descriptors": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", @@ -17439,6 +19093,21 @@ "htmlparser2": "10.0.0" } }, + "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, + "requires": { + "@exodus/bytes": "^1.6.0" + } + }, + "html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true + }, "html-parse-stringify": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", @@ -17531,6 +19200,12 @@ "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "dev": true }, + "indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true + }, "inline-style-parser": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.4.tgz", @@ -17744,6 +19419,12 @@ "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==" }, + "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 + }, "is-promise": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", @@ -17842,6 +19523,33 @@ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" }, + "istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true + }, + "istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "requires": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + } + }, + "istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "requires": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + } + }, "iterator.prototype": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.4.tgz", @@ -17899,6 +19607,43 @@ "argparse": "^2.0.1" } }, + "jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "requires": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "dependencies": { + "lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true + } + } + }, "jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -18168,6 +19913,13 @@ "integrity": "sha512-t5tS44bqd825zAW45UQxpG2CvcC4urOwn2TrwSH8u+MjeE+1NnWl6QqeQ/6NdjMqdOygyiT9p3Ev0p1NJykxjw==", "requires": {} }, + "lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "peer": true + }, "maath": { "version": "0.10.8", "resolved": "https://registry.npmjs.org/maath/-/maath-0.10.8.tgz", @@ -18175,12 +19927,32 @@ "requires": {} }, "magic-string": { - "version": "0.30.5", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.5.tgz", - "integrity": "sha512-7xlpfBaQaP/T6Vh8MO/EqXSW5En6INHEvEXQiuff7Gku0PWjU3uf6w/j9o7O+SpB5fOAkrI5HeoNgwjEO0pFsA==", + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "requires": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "requires": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", "dev": true, "requires": { - "@jridgewell/sourcemap-codec": "^1.4.15" + "semver": "^7.5.3" } }, "markdown-it": { @@ -18446,6 +20218,12 @@ "@types/mdast": "^4.0.0" } }, + "mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true + }, "mdurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", @@ -18707,6 +20485,12 @@ "mime-db": "1.52.0" } }, + "min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true + }, "minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -18848,6 +20632,12 @@ "es-object-atoms": "^1.0.0" } }, + "obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true + }, "optionator": { "version": "0.9.3", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", @@ -18917,6 +20707,23 @@ "lines-and-columns": "^1.1.6" } }, + "parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "requires": { + "entities": "^8.0.0" + }, + "dependencies": { + "entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true + } + } + }, "path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -18938,6 +20745,12 @@ "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==" }, + "pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true + }, "picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -19009,6 +20822,34 @@ "integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==", "dev": true }, + "pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "peer": true, + "requires": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "dependencies": { + "ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "peer": true + }, + "react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "peer": true + } + } + }, "prismjs": { "version": "1.29.0", "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.29.0.tgz", @@ -19476,6 +21317,16 @@ "integrity": "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==", "requires": {} }, + "redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "requires": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + } + }, "reflect.getprototypeof": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.9.tgz", @@ -19678,6 +21529,15 @@ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, + "saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "requires": { + "xmlchars": "^2.2.0" + } + }, "scheduler": { "version": "0.25.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz", @@ -19776,6 +21636,12 @@ "side-channel-map": "^1.0.1" } }, + "siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true + }, "simple-swizzle": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", @@ -19831,6 +21697,12 @@ "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-1.1.5.tgz", "integrity": "sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA==" }, + "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 + }, "stats-gl": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/stats-gl/-/stats-gl-2.4.2.tgz", @@ -19852,6 +21724,12 @@ "resolved": "https://registry.npmjs.org/stats.js/-/stats.js-0.17.0.tgz", "integrity": "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw==" }, + "std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true + }, "stop-iteration-iterator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz", @@ -19980,6 +21858,15 @@ "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", "dev": true }, + "strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "requires": { + "min-indent": "^1.0.0" + } + }, "strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -20007,6 +21894,15 @@ "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==" }, + "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, + "requires": { + "has-flag": "^4.0.0" + } + }, "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", @@ -20027,6 +21923,12 @@ "use-sync-external-store": "^1.4.0" } }, + "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 + }, "tabbable": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", @@ -20085,6 +21987,49 @@ } } }, + "tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true + }, + "tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true + }, + "tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "requires": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "dependencies": { + "fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "requires": {} + }, + "picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true + } + } + }, + "tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true + }, "tippy.js": { "version": "6.3.7", "resolved": "https://registry.npmjs.org/tippy.js/-/tippy.js-6.3.7.tgz", @@ -20093,6 +22038,21 @@ "@popperjs/core": "^2.9.0" } }, + "tldts": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.3.tgz", + "integrity": "sha512-A3BDQBeeukYPzB4QdQ1DtdlUmp4x2OCH8n5UVhEWbyANxNep8GavottKzd1xYKFJKjUgMyPT7EzOfnBO55s8Sg==", + "dev": true, + "requires": { + "tldts-core": "^7.4.3" + } + }, + "tldts-core": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.3.tgz", + "integrity": "sha512-27ep5H9PzdBrNd5OFM/j3WCU8F3kPwM9D0BOaOf7uYfxMJfyr0K5Tjj69Gri+sZlh2WXd5buIm47NuPF29CDiw==", + "dev": true + }, "to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -20102,6 +22062,24 @@ "is-number": "^7.0.0" } }, + "tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "requires": { + "tldts": "^7.0.5" + } + }, + "tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "requires": { + "punycode": "^2.3.1" + } + }, "trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -20283,6 +22261,12 @@ "which-boxed-primitive": "^1.1.1" } }, + "undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "dev": true + }, "undici-types": { "version": "6.19.8", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", @@ -20519,6 +22503,59 @@ "magic-string": "^0.30.1" } }, + "vitest": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "dev": true, + "requires": { + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "dependencies": { + "@vitest/mocker": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", + "dev": true, + "requires": { + "@vitest/spy": "4.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + } + }, + "es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true + }, + "picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true + } + } + }, "void-elements": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", @@ -20529,6 +22566,15 @@ "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==" }, + "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, + "requires": { + "xml-name-validator": "^5.0.0" + } + }, "warning": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", @@ -20552,6 +22598,12 @@ "resolved": "https://registry.npmjs.org/webgl-sdf-generator/-/webgl-sdf-generator-1.1.1.tgz", "integrity": "sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA==" }, + "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 + }, "websocket-driver": { "version": "0.7.4", "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", @@ -20567,6 +22619,23 @@ "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==" }, + "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 + }, + "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, + "requires": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + } + }, "which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -20635,6 +22704,16 @@ "has-tostringtag": "^1.0.2" } }, + "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, + "requires": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + } + }, "wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -20645,6 +22724,18 @@ "strip-ansi": "^6.0.0" } }, + "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 + }, + "xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true + }, "xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index 65ecd8d..1623029 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,10 @@ "prettier:fix": "npm run prettier -- --write", "lint": "npx eslint", "lint:fix": "npm run lint -- --fix", - "format": "npm run prettier:fix && npm run lint:fix" + "format": "npm run prettier:fix && npm run lint:fix", + "test": "vitest", + "test:run": "vitest run", + "test:coverage": "vitest run --coverage" }, "dependencies": { "@floating-ui/react": "^0.27.2", @@ -87,6 +90,9 @@ "@eslint/js": "^9.17.0", "@tailwindcss/postcss": "^4.0.8", "@tailwindcss/typography": "^0.5.15", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/d3-fetch": "^3.0.7", "@types/d3-scale": "^4.0.8", "@types/js-cookie": "^3.0.6", @@ -102,6 +108,7 @@ "@typescript-eslint/eslint-plugin": "^8.5.0", "@typescript-eslint/parser": "^8.5.0", "@vitejs/plugin-react": "^4.3.4", + "@vitest/coverage-v8": "^4.1.9", "color": "^4.2.3", "eslint": "^9.17.0", "eslint-config-prettier": "^9.1.0", @@ -112,6 +119,7 @@ "eslint-plugin-react": "^7.37.3", "eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-refresh": "^0.4.14", + "jsdom": "^29.1.1", "postcss": "^8.4.45", "postcss-nesting": "^13.0.0", "prettier": "^3.3.3", @@ -119,6 +127,7 @@ "typescript": "^5.7.2", "typescript-eslint": "^8.18.2", "vite": "^6.0.5", - "vite-plugin-dynamic-import": "^1.5.0" + "vite-plugin-dynamic-import": "^1.5.0", + "vitest": "^4.1.9" } } diff --git a/src/@types/creation.ts b/src/@types/creation.ts new file mode 100644 index 0000000..10ff949 --- /dev/null +++ b/src/@types/creation.ts @@ -0,0 +1,25 @@ +export interface Creation { + id: string + title: string + thumbnail: string + type: 'pose' | 'render' | 'animation' + views: number + likes: number + createdAt: string + tags: string[] + userId?: string +} + +export interface CreationsResponse { + creations: Creation[] + total: number + nextPage: number | null +} + +export interface RenderPayload { + imageDataUrl: string + poseSnapshot: Record + skinUrl?: string + renderSettings: Record + title?: string +} diff --git a/src/@types/feed.ts b/src/@types/feed.ts new file mode 100644 index 0000000..67208af --- /dev/null +++ b/src/@types/feed.ts @@ -0,0 +1,39 @@ +export interface FeedUser { + id: string + name: string + avatar: string +} + +export interface FeedPost { + id: string + user: FeedUser + type: 'image' | 'video' | 'promo' | 'recommendation' + content: string + likes: number + comments: number + caption: string + tags: string[] + isLiked?: boolean + isBookmarked?: boolean + createdAt: string +} + +export interface FeedResponse { + posts: FeedPost[] + nextPage: number | null + total: number +} + +export interface Story { + id: string + user: FeedUser + content: string + createdAt: string +} + +export interface Challenge { + id: string + name: string + entries: string + previewUrl?: string +} diff --git a/src/App.tsx b/src/App.tsx index 15886e0..f34549a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,7 @@ import Theme from '@/components/template/Theme' import Layout from '@/components/layouts' import { AuthProvider } from '@/auth' import Views from '@/views' +import ErrorBoundary from '@/components/shared/ErrorBoundary' import appConfig from './configs/app.config' import './locales' import 'animate.css'; @@ -13,15 +14,17 @@ if (appConfig.enableMock) { function App() { return ( - - - - - - - - - + + + + + + + + + + + ) } diff --git a/src/components/shared/ErrorBoundary.tsx b/src/components/shared/ErrorBoundary.tsx new file mode 100644 index 0000000..8a1e37e --- /dev/null +++ b/src/components/shared/ErrorBoundary.tsx @@ -0,0 +1,66 @@ +import { Component, ErrorInfo, ReactNode } from 'react' + +interface Props { + children: ReactNode +} + +interface State { + hasError: boolean + error: Error | null +} + +class ErrorBoundary extends Component { + constructor(props: Props) { + super(props) + this.state = { hasError: false, error: null } + } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error } + } + + componentDidCatch(error: Error, info: ErrorInfo) { + console.error('ErrorBoundary caught an error:', error, info) + + // Report to Sentry in production (dynamic import so it's safe if Sentry isn't installed) + if (import.meta.env.PROD) { + import('@sentry/react') + .then(({ captureException }) => { + captureException(error, { extra: { componentStack: info.componentStack } }) + }) + .catch(() => { + // Sentry not installed — silently ignore + }) + } + } + + handleReload = () => { + window.location.reload() + } + + render() { + if (this.state.hasError) { + return ( +
+
+

Something went wrong

+

+ An unexpected error occurred. Our team has been notified. You can try + reloading the page to recover. +

+ +
+
+ ) + } + + return this.props.children + } +} + +export default ErrorBoundary diff --git a/src/components/shared/index.tsx b/src/components/shared/index.tsx index 2040867..c2891e9 100644 --- a/src/components/shared/index.tsx +++ b/src/components/shared/index.tsx @@ -1,5 +1,6 @@ export { default as ActionLink } from './ActionLink' export { default as AuthorityCheck } from './AuthorityCheck' export { default as Container } from './Container' +export { default as ErrorBoundary } from './ErrorBoundary' export { default as NavToggle } from './NavToggle' export { default as PasswordInput } from './PasswordInput' diff --git a/src/configs/app.config.ts b/src/configs/app.config.ts index fe95a58..d744a60 100644 --- a/src/configs/app.config.ts +++ b/src/configs/app.config.ts @@ -14,10 +14,10 @@ export type AppConfig = { const appConfig: AppConfig = { appVersion: '0.0.1', - appUrl: 'https://poselab.nexiotech.cloud', + appUrl: import.meta.env.VITE_APP_URL || 'https://poselab.nexiotech.cloud', appName: 'PoseLab', appDescription: 'PoseLab – Minecraft Skin Posing & Render Tool', - apiBaseUrl: 'https://poselab-backend.nexiotech.cloud', + apiBaseUrl: import.meta.env.VITE_API_BASE_URL || 'https://poselab-backend.nexiotech.cloud', apiPrefix: '/api', authenticatedEntryPath: '/home', unAuthenticatedEntryPath: '/sign-in', diff --git a/src/configs/endpoint.config.ts b/src/configs/endpoint.config.ts index 155fef4..559bee8 100644 --- a/src/configs/endpoint.config.ts +++ b/src/configs/endpoint.config.ts @@ -1,13 +1,34 @@ export const apiPrefix = '/api' const endpointConfig = { + // Auth (existing) signIn: '/auth', signOut: '/sign-out', signUp: '/signup', forgotPassword: '/forgot-password', resetPassword: '/reset-password', - googleSignIn: '/auth', // Add this for Google OAuth - githubSignIn: '/auth', // Add this for GitHub OAuth + googleSignIn: '/auth', + githubSignIn: '/auth', + // Feed + feed: '/feed', + feedPost: '/feed', + feedLike: '/feed/:postId/like', + feedBookmark: '/feed/:postId/bookmark', + // Renders + renders: '/renders', + renderById: '/renders/:renderId', + // Poses + poses: '/poses', + poseById: '/poses/:poseId', + // Profile + profileById: '/profile/:userId', + profileUpdate: '/profile', + // Stories + stories: '/stories', + // Challenges + challenges: '/challenges', + // Reports + reports: '/reports', } export default endpointConfig diff --git a/src/firebase.ts b/src/firebase.ts index 1b35482..9270dcd 100644 --- a/src/firebase.ts +++ b/src/firebase.ts @@ -1,31 +1,24 @@ -// src/firebase.ts import { initializeApp } from "firebase/app"; import { getAnalytics, isSupported } from "firebase/analytics"; import { getAuth } from 'firebase/auth' - const firebaseConfig = { - apiKey: "AIzaSyDlxnhkCH_m4CVVCdjgzF7BAS4YAk6Zn10", - authDomain: "poselab-584a8.firebaseapp.com", - projectId: "poselab-584a8", - storageBucket: "poselab-584a8.firebasestorage.app", - messagingSenderId: "350044736479", - appId: "1:350044736479:web:aa43b1ceb0622a10a9526a", - measurementId: "G-ZL15YJRKHY" + apiKey: import.meta.env.VITE_FIREBASE_API_KEY, + authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN, + projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID, + storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET, + messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID, + appId: import.meta.env.VITE_FIREBASE_APP_ID, + measurementId: import.meta.env.VITE_FIREBASE_MEASUREMENT_ID, }; -// Initialize Firebase const app = initializeApp(firebaseConfig); -const auth = getAuth(app) +const auth = getAuth(app) -// Initialize Analytics (only if supported in this environment) let analytics: ReturnType | undefined; isSupported().then((yes) => { if (yes) { analytics = getAnalytics(app); - console.log("Firebase Analytics initialized 🚀"); - } else { - console.log("Analytics not supported in this environment"); } }); diff --git a/src/lib/sentry.ts b/src/lib/sentry.ts new file mode 100644 index 0000000..736f63b --- /dev/null +++ b/src/lib/sentry.ts @@ -0,0 +1,16 @@ +// Initialize Sentry for production error tracking +// Install @sentry/react before using: npm install @sentry/react +// Then call initSentry() in main.tsx before React renders + +export function initSentry() { + if (import.meta.env.PROD && import.meta.env.VITE_SENTRY_DSN) { + import('@sentry/react').then(({ init, browserTracingIntegration }) => { + init({ + dsn: import.meta.env.VITE_SENTRY_DSN, + integrations: [browserTracingIntegration()], + tracesSampleRate: 0.1, + environment: import.meta.env.MODE, + }) + }) + } +} diff --git a/src/services/CreationService.ts b/src/services/CreationService.ts new file mode 100644 index 0000000..2cd5a69 --- /dev/null +++ b/src/services/CreationService.ts @@ -0,0 +1,30 @@ +import ApiService from './ApiService' +import endpointConfig from '@/configs/endpoint.config' +import type { CreationsResponse, Creation, RenderPayload } from '@/@types/creation' + +const CreationService = { + async getMyCreations(userId: string, page = 1, limit = 20): Promise { + return ApiService.fetchDataWithAxios({ + url: endpointConfig.renders, + method: 'GET', + params: { userId, page, limit }, + }) + }, + + async saveRender(payload: RenderPayload): Promise { + return ApiService.fetchDataWithAxios({ + url: endpointConfig.renders, + method: 'POST', + data: payload, + }) + }, + + async deleteCreation(renderId: string): Promise { + return ApiService.fetchDataWithAxios({ + url: endpointConfig.renderById.replace(':renderId', renderId), + method: 'DELETE', + }) + }, +} + +export default CreationService diff --git a/src/services/FeedService.ts b/src/services/FeedService.ts new file mode 100644 index 0000000..ed81af5 --- /dev/null +++ b/src/services/FeedService.ts @@ -0,0 +1,135 @@ +import ApiService from './ApiService' +import endpointConfig from '@/configs/endpoint.config' +import type { FeedResponse, Story, Challenge } from '@/@types/feed' + +const FALLBACK_POSTS = [ + { + id: 1, + user: { name: 'AlexCraft', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Alex' }, + type: 'image', + content: '/poseLabsPose/standing.png', + likes: 1240, + comments: 45, + caption: 'Just finished the cinematic standing pose! The lighting in PoseLab is insane! 🔥 #Minecraft #Render', + tags: ['#3DRendering', '#MinecraftArt'], + }, + { + id: 2, + user: { name: 'SteveTheGod', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Steve' }, + type: 'video', + content: '/poseLabsPose/walking.png', + likes: 890, + comments: 12, + caption: 'Experimenting with the bendable model. The joints feel so smooth now. 🧊', + tags: ['#BendableModel', '#Animation'], + }, + { + id: 'promo-1', + type: 'promo', + title: 'New Bendable Rig v2.0', + subtitle: 'Experience ultimate flexibility in your renders with our upgraded vertex-weight systems.', + cta: 'Upgrade Rig', + path: '/3d-viewer', + image: 'https://images.unsplash.com/photo-1614850523296-d8c1af93d400?q=80&w=2070&auto=format&fit=crop', + }, + { + id: 3, + user: { name: 'CreativeCat', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Cat' }, + type: 'image', + content: '/poseLabsPose/combat.png', + likes: 2100, + comments: 156, + caption: 'Action sequence render! Ready for the battle. ⚔️', + tags: ['#CombatPose', '#Epic'], + }, + { + id: 4, + user: { name: 'PixelMaster', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Pixel' }, + type: 'image', + content: '/poseLabsPose/walking.png', + likes: 560, + comments: 8, + caption: 'Starting small with basic walks. Practice makes perfect! 🚶‍♂️', + tags: ['#Beginner', '#PosePractice'], + }, +] + +const FALLBACK_STORIES: Story[] = [ + { id: '1', user: { id: '1', name: 'AlexCraft', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Alex' }, content: '/poseLabsPose/standing.png', createdAt: new Date().toISOString() }, + { id: '2', user: { id: '2', name: 'SteveTheGod', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Steve' }, content: '/poseLabsPose/walking.png', createdAt: new Date().toISOString() }, + { id: '3', user: { id: '3', name: 'CreativeCat', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Cat' }, content: '/poseLabsPose/combat.png', createdAt: new Date().toISOString() }, + { id: '4', user: { id: '4', name: 'PixelMaster', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Pixel' }, content: '/poseLabsPose/walking.png', createdAt: new Date().toISOString() }, +] + +const FALLBACK_CHALLENGES: Challenge[] = [ + { id: '1', name: 'SunlightShadows', entries: '1.2k' }, + { id: '2', name: 'RigidDynamics', entries: '850' }, + { id: '3', name: 'EpicCombat', entries: '2.4k' }, +] + +const FeedService = { + async getFeed(page = 1, limit = 10): Promise { + try { + return await ApiService.fetchDataWithAxios({ + url: endpointConfig.feed, + method: 'GET', + params: { page, limit }, + }) + } catch { + if (page === 1) return { posts: FALLBACK_POSTS as any, nextPage: null, total: FALLBACK_POSTS.length } + return { posts: [], nextPage: null, total: 0 } + } + }, + + async likePost(postId: string): Promise { + try { + await ApiService.fetchDataWithAxios({ + url: endpointConfig.feedLike.replace(':postId', postId), + method: 'POST', + }) + } catch { /* fire-and-forget */ } + }, + + async bookmarkPost(postId: string): Promise { + try { + await ApiService.fetchDataWithAxios({ + url: endpointConfig.feedBookmark.replace(':postId', postId), + method: 'POST', + }) + } catch { /* fire-and-forget */ } + }, + + async getStories(): Promise { + try { + return await ApiService.fetchDataWithAxios({ + url: endpointConfig.stories, + method: 'GET', + }) + } catch { + return FALLBACK_STORIES + } + }, + + async getChallenges(): Promise { + try { + return await ApiService.fetchDataWithAxios({ + url: endpointConfig.challenges, + method: 'GET', + }) + } catch { + return FALLBACK_CHALLENGES + } + }, + + async reportPost(postId: string | number): Promise { + try { + await ApiService.fetchDataWithAxios({ + url: endpointConfig.reports, + method: 'POST', + data: { postId, reason: 'user_report' }, + }) + } catch { /* silently ignore — a failed report must not degrade UX */ } + }, +} + +export default FeedService diff --git a/src/test/integration/authService.test.ts b/src/test/integration/authService.test.ts new file mode 100644 index 0000000..c921586 --- /dev/null +++ b/src/test/integration/authService.test.ts @@ -0,0 +1,159 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import axios from 'axios' +import MockAdapter from 'axios-mock-adapter' + +// Mock js-cookie (needed by cookiesStorage used in the store) +vi.mock('js-cookie', () => ({ + default: { + get: vi.fn(), + set: vi.fn(), + remove: vi.fn(), + }, +})) + +// We import AxiosBase and mock at the adapter level +import AxiosBase from '@/services/axios/AxiosBase' +import { apiSignIn, apiSignUp, apiSignOut } from '@/services/AuthService' +import endpointConfig from '@/configs/endpoint.config' +import type { ApiSignInResponse, ApiSignUpResponse } from '@/@types/auth' + +const mock = new MockAdapter(AxiosBase) + +beforeEach(() => { + mock.reset() +}) + +afterEach(() => { + mock.reset() +}) + +const mockSignInResponse: ApiSignInResponse = { + status: 200, + success: true, + Token: 'test-jwt-token', + User: { + uid: 'firebase-uid-001', + email: 'user@example.com', + phone: null, + provider: 'password', + createdAt: { _seconds: 1700000000, _nanoseconds: 0 }, + }, +} + +const mockSignUpResponse: ApiSignUpResponse = { + status: 201, + success: true, + Token: 'signup-jwt-token', + User: { + uid: 'firebase-uid-002', + email: 'newuser@example.com', + provider: 'password', + createdAt: '2024-01-01T00:00:00Z', + }, +} + +describe('apiSignIn', () => { + it('calls the signIn endpoint with correct payload structure', async () => { + mock.onPost(endpointConfig.signIn).reply(200, mockSignInResponse) + + await apiSignIn({ email: 'user@example.com', password: 'secret' }) + + expect(mock.history.post).toHaveLength(1) + const requestBody = JSON.parse(mock.history.post[0].data) + expect(requestBody.login_type_id).toBe(4) + expect(requestBody.payload.email).toBe('user@example.com') + expect(requestBody.payload.password).toBe('secret') + }) + + it('returns mapped user and token on success', async () => { + mock.onPost(endpointConfig.signIn).reply(200, mockSignInResponse) + + const result = await apiSignIn({ email: 'user@example.com', password: 'secret' }) + + expect(result.token).toBe('test-jwt-token') + expect(result.user.userId).toBe('firebase-uid-001') + expect(result.user.email).toBe('user@example.com') + expect(result.user.authority).toEqual(['USER']) + }) + + it('throws on 401 Unauthorized', async () => { + mock.onPost(endpointConfig.signIn).reply(401, { message: 'Invalid credentials' }) + + await expect( + apiSignIn({ email: 'wrong@example.com', password: 'wrongpass' }), + ).rejects.toThrow() + }) + + it('throws on 500 server error', async () => { + mock.onPost(endpointConfig.signIn).reply(500, { message: 'Internal Server Error' }) + + await expect( + apiSignIn({ email: 'user@example.com', password: 'password' }), + ).rejects.toThrow() + }) + + it('throws on network error', async () => { + mock.onPost(endpointConfig.signIn).networkError() + + await expect( + apiSignIn({ email: 'user@example.com', password: 'password' }), + ).rejects.toThrow() + }) +}) + +describe('apiSignUp', () => { + it('calls the signUp endpoint with correct payload', async () => { + mock.onPost(endpointConfig.signUp).reply(201, mockSignUpResponse) + + await apiSignUp({ userName: 'newuser', email: 'newuser@example.com', password: 'newpass' }) + + expect(mock.history.post).toHaveLength(1) + const requestBody = JSON.parse(mock.history.post[0].data) + expect(requestBody.username).toBe('newuser') + expect(requestBody.email).toBe('newuser@example.com') + expect(requestBody.password).toBe('newpass') + }) + + it('returns mapped user and token on successful sign-up', async () => { + mock.onPost(endpointConfig.signUp).reply(201, mockSignUpResponse) + + const result = await apiSignUp({ userName: 'newuser', email: 'newuser@example.com', password: 'newpass' }) + + expect(result.token).toBe('signup-jwt-token') + expect(result.user.userId).toBe('firebase-uid-002') + expect(result.user.email).toBe('newuser@example.com') + }) + + it('throws on 400 validation error', async () => { + mock.onPost(endpointConfig.signUp).reply(400, { message: 'Email already in use' }) + + await expect( + apiSignUp({ userName: 'newuser', email: 'existing@example.com', password: 'pass' }), + ).rejects.toThrow() + }) +}) + +describe('apiSignOut', () => { + it('calls the signOut endpoint', async () => { + mock.onPost(endpointConfig.signOut).reply(200, { success: true }) + + await apiSignOut() + + expect(mock.history.post).toHaveLength(1) + expect(mock.history.post[0].url).toBe(endpointConfig.signOut) + }) + + it('uses POST method for sign-out', async () => { + mock.onPost(endpointConfig.signOut).reply(200, {}) + + await apiSignOut() + + expect(mock.history.post[0].method).toBe('post') + }) + + it('throws if server returns an error on sign-out', async () => { + mock.onPost(endpointConfig.signOut).reply(500, { message: 'Server error' }) + + await expect(apiSignOut()).rejects.toThrow() + }) +}) diff --git a/src/test/setup.ts b/src/test/setup.ts new file mode 100644 index 0000000..1bea2f9 --- /dev/null +++ b/src/test/setup.ts @@ -0,0 +1,36 @@ +import '@testing-library/jest-dom' +import { vi } from 'vitest' + +// Mock Firebase to prevent initialization errors in tests +vi.mock('firebase/app', () => ({ + initializeApp: vi.fn(() => ({})), +})) +vi.mock('firebase/auth', () => ({ + getAuth: vi.fn(() => ({})), + signInWithEmailAndPassword: vi.fn(), + createUserWithEmailAndPassword: vi.fn(), + signOut: vi.fn(), + onAuthStateChanged: vi.fn(), +})) +vi.mock('firebase/analytics', () => ({ + getAnalytics: vi.fn(), + isSupported: vi.fn(() => Promise.resolve(false)), +})) + +// Mock import.meta.env +Object.defineProperty(import.meta, 'env', { + value: { + VITE_FIREBASE_API_KEY: 'test-key', + VITE_FIREBASE_AUTH_DOMAIN: 'test.firebaseapp.com', + VITE_FIREBASE_PROJECT_ID: 'test-project', + VITE_FIREBASE_STORAGE_BUCKET: 'test.firebasestorage.app', + VITE_FIREBASE_MESSAGING_SENDER_ID: '123456', + VITE_FIREBASE_APP_ID: 'test-app-id', + VITE_FIREBASE_MEASUREMENT_ID: 'test-measurement', + VITE_API_BASE_URL: 'https://test-api.example.com', + VITE_APP_URL: 'https://test.example.com', + PROD: false, + MODE: 'test', + }, + writable: true, +}) diff --git a/src/test/unit/appConfig.test.ts b/src/test/unit/appConfig.test.ts new file mode 100644 index 0000000..38e298f --- /dev/null +++ b/src/test/unit/appConfig.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect } from 'vitest' +import appConfig from '@/configs/app.config' +import type { AppConfig } from '@/configs/app.config' + +describe('appConfig', () => { + it('has all required fields', () => { + expect(appConfig).toHaveProperty('appUrl') + expect(appConfig).toHaveProperty('appName') + expect(appConfig).toHaveProperty('apiBaseUrl') + expect(appConfig).toHaveProperty('apiPrefix') + expect(appConfig).toHaveProperty('authenticatedEntryPath') + expect(appConfig).toHaveProperty('unAuthenticatedEntryPath') + expect(appConfig).toHaveProperty('locale') + expect(appConfig).toHaveProperty('accessTokenPersistStrategy') + expect(appConfig).toHaveProperty('enableMock') + }) + + it('has apiPrefix set to /api', () => { + expect(appConfig.apiPrefix).toBe('/api') + }) + + it('has accessTokenPersistStrategy set to cookies', () => { + expect(appConfig.accessTokenPersistStrategy).toBe('cookies') + }) + + it('has a non-empty appName', () => { + expect(typeof appConfig.appName).toBe('string') + expect(appConfig.appName.length).toBeGreaterThan(0) + }) + + it('has a valid authenticatedEntryPath starting with /', () => { + expect(appConfig.authenticatedEntryPath).toMatch(/^\//) + }) + + it('has a valid unAuthenticatedEntryPath starting with /', () => { + expect(appConfig.unAuthenticatedEntryPath).toMatch(/^\//) + }) + + it('has a locale string', () => { + expect(typeof appConfig.locale).toBe('string') + expect(appConfig.locale.length).toBeGreaterThan(0) + }) + + it('accepts valid AppConfig shape', () => { + const validConfig: AppConfig = { + appUrl: 'https://example.com', + appName: 'TestApp', + apiBaseUrl: 'https://api.example.com', + apiPrefix: '/api/v1', + authenticatedEntryPath: '/dashboard', + unAuthenticatedEntryPath: '/login', + locale: 'en', + accessTokenPersistStrategy: 'localStorage', + enableMock: false, + } + // TypeScript check — this compiles only if the shape is correct + expect(validConfig.appName).toBe('TestApp') + expect(validConfig.accessTokenPersistStrategy).toBe('localStorage') + }) + + it('accessTokenPersistStrategy is one of the valid union types', () => { + const validStrategies: AppConfig['accessTokenPersistStrategy'][] = [ + 'localStorage', + 'sessionStorage', + 'cookies', + ] + expect(validStrategies).toContain(appConfig.accessTokenPersistStrategy) + }) +}) diff --git a/src/test/unit/authMapper.test.ts b/src/test/unit/authMapper.test.ts new file mode 100644 index 0000000..83d62cf --- /dev/null +++ b/src/test/unit/authMapper.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect, vi } from 'vitest' +import { mapApiResponseToUser } from '@/utils/authMapper' +import type { ApiSignInResponse, ApiSignUpResponse } from '@/@types/auth' + +describe('mapApiResponseToUser', () => { + const validSignInResponse: ApiSignInResponse = { + status: 200, + success: true, + Token: 'abc123token', + User: { + uid: 'user-uid-1', + email: 'test@example.com', + phone: null, + provider: 'password', + createdAt: { _seconds: 1700000000, _nanoseconds: 0 }, + }, + } + + const validSignUpResponse: ApiSignUpResponse = { + status: 201, + success: true, + Token: 'signupToken456', + User: { + uid: 'user-uid-2', + email: 'newuser@example.com', + provider: 'password', + createdAt: '2024-01-01T00:00:00Z', + }, + } + + describe('with a sign-in response', () => { + it('extracts the token from the Token field', () => { + const result = mapApiResponseToUser(validSignInResponse) + expect(result.token).toBe('abc123token') + }) + + it('maps uid to userId', () => { + const result = mapApiResponseToUser(validSignInResponse) + expect(result.user.userId).toBe('user-uid-1') + }) + + it('maps email to email and userName', () => { + const result = mapApiResponseToUser(validSignInResponse) + expect(result.user.email).toBe('test@example.com') + expect(result.user.userName).toBe('test@example.com') + }) + + it('sets default authority to [USER]', () => { + const result = mapApiResponseToUser(validSignInResponse) + expect(result.user.authority).toEqual(['USER']) + }) + + it('sets avatar to empty string by default', () => { + const result = mapApiResponseToUser(validSignInResponse) + expect(result.user.avatar).toBe('') + }) + }) + + describe('with a sign-up response', () => { + it('extracts the token from the Token field', () => { + const result = mapApiResponseToUser(validSignUpResponse) + expect(result.token).toBe('signupToken456') + }) + + it('maps uid to userId', () => { + const result = mapApiResponseToUser(validSignUpResponse) + expect(result.user.userId).toBe('user-uid-2') + }) + + it('maps email correctly', () => { + const result = mapApiResponseToUser(validSignUpResponse) + expect(result.user.email).toBe('newuser@example.com') + }) + + it('returns authority as [USER]', () => { + const result = mapApiResponseToUser(validSignUpResponse) + expect(result.user.authority).toEqual(['USER']) + }) + }) + + describe('when User object is missing', () => { + it('returns a fallback user with empty strings', () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const badResponse = { ...validSignInResponse, User: undefined as any } + const result = mapApiResponseToUser(badResponse) + expect(result.user.userId).toBe('') + expect(result.user.userName).toBe('Unknown') + expect(result.user.email).toBe('') + expect(result.user.authority).toEqual(['USER']) + consoleSpy.mockRestore() + }) + + it('logs an error when User object is missing', () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const badResponse = { ...validSignInResponse, User: undefined as any } + mapApiResponseToUser(badResponse) + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('API Response missing User object'), + expect.anything(), + ) + consoleSpy.mockRestore() + }) + }) + + describe('when email is null (phone-only user)', () => { + it('falls back to phone if email is null', () => { + const phoneUser: ApiSignInResponse = { + ...validSignInResponse, + User: { + ...validSignInResponse.User, + email: null, + phone: '+1234567890', + }, + } + const result = mapApiResponseToUser(phoneUser) + expect(result.user.email).toBe('') + // userName falls back to phone when email is null + expect(result.user.userName).toBe('+1234567890') + }) + + it('uses "User" as fallback userName when both email and phone are absent', () => { + const noEmailNoPhone: ApiSignInResponse = { + ...validSignInResponse, + User: { + ...validSignInResponse.User, + email: null, + phone: null, + }, + } + const result = mapApiResponseToUser(noEmailNoPhone) + expect(result.user.userName).toBe('User') + }) + }) +}) diff --git a/src/test/unit/authStore.test.ts b/src/test/unit/authStore.test.ts new file mode 100644 index 0000000..c016b71 --- /dev/null +++ b/src/test/unit/authStore.test.ts @@ -0,0 +1,167 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' + +// Mock js-cookie before importing store (cookiesStorage uses it) +vi.mock('js-cookie', () => ({ + default: { + get: vi.fn(), + set: vi.fn(), + remove: vi.fn(), + }, +})) + +import { useSessionUser, useToken } from '@/store/authStore' +import { TOKEN_NAME_IN_STORAGE } from '@/constants/api.constant' +import type { User } from '@/@types/auth' + +// Reset zustand store state between tests +beforeEach(() => { + useSessionUser.setState({ + session: { signedIn: false }, + user: { + avatar: '', + userName: '', + email: '', + authority: [], + }, + }) + + // Clear localStorage/sessionStorage between tests + localStorage.clear() + sessionStorage.clear() +}) + +describe('useSessionUser store — initial state', () => { + it('starts with signedIn = false', () => { + const { session } = useSessionUser.getState() + expect(session.signedIn).toBe(false) + }) + + it('starts with an empty user', () => { + const { user } = useSessionUser.getState() + expect(user.userName).toBe('') + expect(user.email).toBe('') + expect(user.avatar).toBe('') + expect(user.authority).toEqual([]) + }) +}) + +describe('useSessionUser store — setSessionSignedIn', () => { + it('sets signedIn to true', () => { + useSessionUser.getState().setSessionSignedIn(true) + expect(useSessionUser.getState().session.signedIn).toBe(true) + }) + + it('sets signedIn back to false', () => { + useSessionUser.getState().setSessionSignedIn(true) + useSessionUser.getState().setSessionSignedIn(false) + expect(useSessionUser.getState().session.signedIn).toBe(false) + }) +}) + +describe('useSessionUser store — setUser', () => { + it('updates the user state with new values', () => { + const newUser: User = { + userId: 'uid-123', + userName: 'John', + email: 'john@example.com', + avatar: '/avatar.png', + authority: ['USER'], + } + useSessionUser.getState().setUser(newUser) + const { user } = useSessionUser.getState() + expect(user.userId).toBe('uid-123') + expect(user.userName).toBe('John') + expect(user.email).toBe('john@example.com') + expect(user.avatar).toBe('/avatar.png') + expect(user.authority).toEqual(['USER']) + }) + + it('merges partial user updates without erasing other fields', () => { + // Set a full user first + useSessionUser.getState().setUser({ + userId: 'uid-1', + userName: 'Alice', + email: 'alice@example.com', + avatar: '/alice.png', + authority: ['ADMIN'], + }) + // Now update only the email + useSessionUser.getState().setUser({ email: 'newalice@example.com' }) + const { user } = useSessionUser.getState() + expect(user.userName).toBe('Alice') + expect(user.email).toBe('newalice@example.com') + }) +}) + +describe('useToken — setToken and getToken', () => { + it('setToken stores token in localStorage', () => { + const { setToken } = useToken() + setToken('my-access-token') + expect(localStorage.getItem(TOKEN_NAME_IN_STORAGE)).toBe('my-access-token') + }) + + it('setToken stores token in sessionStorage', () => { + const { setToken } = useToken() + setToken('session-token') + expect(sessionStorage.getItem(TOKEN_NAME_IN_STORAGE)).toBe('session-token') + }) + + it('setToken with empty string clears localStorage', () => { + localStorage.setItem(TOKEN_NAME_IN_STORAGE, 'old-token') + const { setToken } = useToken() + setToken('') + expect(localStorage.getItem(TOKEN_NAME_IN_STORAGE)).toBeNull() + }) + + it('setToken with empty string clears sessionStorage', () => { + sessionStorage.setItem(TOKEN_NAME_IN_STORAGE, 'old-token') + const { setToken } = useToken() + setToken('') + expect(sessionStorage.getItem(TOKEN_NAME_IN_STORAGE)).toBeNull() + }) + + it('token is readable from localStorage after setToken', () => { + localStorage.setItem(TOKEN_NAME_IN_STORAGE, 'readable-token') + const { token } = useToken() + expect(token).toBe('readable-token') + }) + + it('token is null/falsy when no token is stored', () => { + localStorage.clear() + sessionStorage.clear() + const { token } = useToken() + // With js-cookie mocked to return undefined, token should be falsy + expect(token).toBeFalsy() + }) +}) + +describe('sign-out flow', () => { + it('clears user and session state on sign-out', () => { + // Set up a signed-in user + useSessionUser.getState().setSessionSignedIn(true) + useSessionUser.getState().setUser({ + userId: 'uid-1', + userName: 'Alice', + email: 'alice@example.com', + authority: ['USER'], + }) + + // Simulate sign-out: clear token and reset session + const { setToken } = useToken() + setToken('') + useSessionUser.getState().setSessionSignedIn(false) + useSessionUser.getState().setUser({ + userId: undefined, + userName: '', + email: '', + avatar: '', + authority: [], + }) + + const state = useSessionUser.getState() + expect(state.session.signedIn).toBe(false) + expect(state.user.email).toBe('') + expect(state.user.authority).toEqual([]) + expect(localStorage.getItem(TOKEN_NAME_IN_STORAGE)).toBeNull() + }) +}) diff --git a/src/test/unit/cookiesStorage.test.ts b/src/test/unit/cookiesStorage.test.ts new file mode 100644 index 0000000..4c92cba --- /dev/null +++ b/src/test/unit/cookiesStorage.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// Mock js-cookie before importing cookiesStorage +vi.mock('js-cookie', () => ({ + default: { + get: vi.fn(), + set: vi.fn(), + remove: vi.fn(), + }, +})) + +import cookiesStorage from '@/utils/cookiesStorage' +import Cookies from 'js-cookie' + +const mockCookies = Cookies as unknown as { + get: ReturnType + set: ReturnType + remove: ReturnType +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('cookiesStorage.getItem', () => { + it('returns the cookie value when it exists', () => { + mockCookies.get.mockReturnValue('my-token') + const result = cookiesStorage.getItem('token') + expect(result).toBe('my-token') + expect(mockCookies.get).toHaveBeenCalledWith('token') + }) + + it('returns null when the cookie does not exist', () => { + mockCookies.get.mockReturnValue(undefined) + const result = cookiesStorage.getItem('missing-key') + expect(result).toBeNull() + }) + + it('calls Cookies.get with the correct name', () => { + mockCookies.get.mockReturnValue('value') + cookiesStorage.getItem('auth-session') + expect(mockCookies.get).toHaveBeenCalledWith('auth-session') + }) +}) + +describe('cookiesStorage.setItem', () => { + it('calls Cookies.set with the name and value', () => { + cookiesStorage.setItem('token', 'abc123') + expect(mockCookies.set).toHaveBeenCalledWith('token', 'abc123', { expires: 1, path: '/' }) + }) + + it('uses the provided expires value', () => { + cookiesStorage.setItem('token', 'xyz', 7) + expect(mockCookies.set).toHaveBeenCalledWith('token', 'xyz', { expires: 7, path: '/' }) + }) + + it('accepts a Date object as expires', () => { + const expiryDate = new Date('2099-01-01') + cookiesStorage.setItem('session', 'data', expiryDate) + expect(mockCookies.set).toHaveBeenCalledWith('session', 'data', { expires: expiryDate, path: '/' }) + }) + + it('defaults to expires=1 when no expiry is provided', () => { + cookiesStorage.setItem('key', 'value') + expect(mockCookies.set).toHaveBeenCalledWith('key', 'value', { expires: 1, path: '/' }) + }) + + it('sets the path to /', () => { + cookiesStorage.setItem('key', 'value') + const callArgs = mockCookies.set.mock.calls[0] + expect(callArgs[2]).toMatchObject({ path: '/' }) + }) +}) + +describe('cookiesStorage.removeItem', () => { + it('calls Cookies.remove with the correct name', () => { + cookiesStorage.removeItem('token') + expect(mockCookies.remove).toHaveBeenCalledWith('token') + }) + + it('removes the specified key', () => { + cookiesStorage.removeItem('auth-session') + expect(mockCookies.remove).toHaveBeenCalledWith('auth-session') + expect(mockCookies.remove).not.toHaveBeenCalledWith('other-key') + }) +}) diff --git a/src/test/unit/qualitySettings.test.ts b/src/test/unit/qualitySettings.test.ts new file mode 100644 index 0000000..3e7828f --- /dev/null +++ b/src/test/unit/qualitySettings.test.ts @@ -0,0 +1,155 @@ +import { describe, it, expect, vi, beforeAll } from 'vitest' + +// Mock THREE before importing qualitySettings since it uses window.devicePixelRatio +// and THREE constants at module evaluation time +vi.mock('three', () => ({ + BasicShadowMap: 0, + PCFShadowMap: 1, + PCFSoftShadowMap: 2, + VSMShadowMap: 3, +})) + +// jsdom doesn't set devicePixelRatio by default, so set it before import +beforeAll(() => { + Object.defineProperty(window, 'devicePixelRatio', { + value: 2, + writable: true, + }) +}) + +import { + QUALITY_PRESETS, + getQualityPreset, + getShadowMapType, + type QualityLevel, + type QualityPreset, +} from '@/views/users/LabUtils/qualitySettings' + +describe('QUALITY_PRESETS', () => { + it('defines all 4 quality levels', () => { + const levels: QualityLevel[] = ['low', 'medium', 'high', 'ultra'] + for (const level of levels) { + expect(QUALITY_PRESETS).toHaveProperty(level) + } + }) + + it('each preset has all required rendering fields', () => { + const requiredFields: (keyof QualityPreset)[] = [ + 'shadowMapSize', + 'shadowType', + 'shadowBias', + 'shadowNormalBias', + 'antialias', + 'pixelRatio', + 'toneMapping', + 'toneMappingExposure', + 'postProcessing', + 'bloom', + 'ssao', + 'dof', + 'maxLights', + 'envMapIntensity', + 'textureAnisotropy', + 'lodDistances', + ] + for (const level of Object.keys(QUALITY_PRESETS) as QualityLevel[]) { + const preset = QUALITY_PRESETS[level] + for (const field of requiredFields) { + expect(preset, `${level} preset missing ${field}`).toHaveProperty(field) + } + } + }) + + it('shadowMapSize increases with quality level', () => { + expect(QUALITY_PRESETS.low.shadowMapSize).toBeLessThan(QUALITY_PRESETS.medium.shadowMapSize) + expect(QUALITY_PRESETS.medium.shadowMapSize).toBeLessThan(QUALITY_PRESETS.high.shadowMapSize) + expect(QUALITY_PRESETS.high.shadowMapSize).toBeLessThan(QUALITY_PRESETS.ultra.shadowMapSize) + }) + + it('maxLights increases with quality level', () => { + expect(QUALITY_PRESETS.low.maxLights).toBeLessThan(QUALITY_PRESETS.medium.maxLights) + expect(QUALITY_PRESETS.medium.maxLights).toBeLessThan(QUALITY_PRESETS.high.maxLights) + expect(QUALITY_PRESETS.high.maxLights).toBeLessThan(QUALITY_PRESETS.ultra.maxLights) + }) + + it('textureAnisotropy increases with quality level', () => { + expect(QUALITY_PRESETS.low.textureAnisotropy).toBeLessThan(QUALITY_PRESETS.medium.textureAnisotropy) + expect(QUALITY_PRESETS.medium.textureAnisotropy).toBeLessThan(QUALITY_PRESETS.high.textureAnisotropy) + expect(QUALITY_PRESETS.high.textureAnisotropy).toBeLessThan(QUALITY_PRESETS.ultra.textureAnisotropy) + }) + + it('low preset has antialias disabled', () => { + expect(QUALITY_PRESETS.low.antialias).toBe(false) + }) + + it('medium/high/ultra presets have antialias enabled', () => { + expect(QUALITY_PRESETS.medium.antialias).toBe(true) + expect(QUALITY_PRESETS.high.antialias).toBe(true) + expect(QUALITY_PRESETS.ultra.antialias).toBe(true) + }) + + it('low preset has no post-processing features', () => { + expect(QUALITY_PRESETS.low.postProcessing).toBe(false) + expect(QUALITY_PRESETS.low.bloom).toBe(false) + expect(QUALITY_PRESETS.low.ssao).toBe(false) + expect(QUALITY_PRESETS.low.dof).toBe(false) + }) + + it('ultra preset enables all post-processing features', () => { + expect(QUALITY_PRESETS.ultra.postProcessing).toBe(true) + expect(QUALITY_PRESETS.ultra.bloom).toBe(true) + expect(QUALITY_PRESETS.ultra.ssao).toBe(true) + expect(QUALITY_PRESETS.ultra.dof).toBe(true) + }) + + it('lodDistances is a tuple of 3 numbers for each preset', () => { + for (const level of Object.keys(QUALITY_PRESETS) as QualityLevel[]) { + const { lodDistances } = QUALITY_PRESETS[level] + expect(Array.isArray(lodDistances)).toBe(true) + expect(lodDistances).toHaveLength(3) + expect(typeof lodDistances[0]).toBe('number') + expect(typeof lodDistances[1]).toBe('number') + expect(typeof lodDistances[2]).toBe('number') + } + }) + + it('lodDistances range increases with quality', () => { + const maxDist = (preset: QualityPreset) => preset.lodDistances[2] + expect(maxDist(QUALITY_PRESETS.low)).toBeLessThan(maxDist(QUALITY_PRESETS.medium)) + expect(maxDist(QUALITY_PRESETS.medium)).toBeLessThan(maxDist(QUALITY_PRESETS.high)) + expect(maxDist(QUALITY_PRESETS.high)).toBeLessThan(maxDist(QUALITY_PRESETS.ultra)) + }) +}) + +describe('getQualityPreset', () => { + it('returns the correct preset for each quality level', () => { + const levels: QualityLevel[] = ['low', 'medium', 'high', 'ultra'] + for (const level of levels) { + expect(getQualityPreset(level)).toBe(QUALITY_PRESETS[level]) + } + }) +}) + +describe('getShadowMapType', () => { + it('returns BasicShadowMap for basic', () => { + // THREE is mocked; value is 0 + expect(getShadowMapType('basic')).toBe(0) + }) + + it('returns PCFShadowMap for pcf', () => { + expect(getShadowMapType('pcf')).toBe(1) + }) + + it('returns PCFSoftShadowMap for pcfSoft', () => { + expect(getShadowMapType('pcfSoft')).toBe(2) + }) + + it('returns VSMShadowMap for vsm', () => { + expect(getShadowMapType('vsm')).toBe(3) + }) + + it('returns PCFShadowMap as fallback for unknown type', () => { + // Cast to bypass TS for testing the default branch + expect(getShadowMapType('unknown' as any)).toBe(1) + }) +}) diff --git a/src/views/Home/components/PostCard.tsx b/src/views/Home/components/PostCard.tsx index 5765286..c365e4b 100644 --- a/src/views/Home/components/PostCard.tsx +++ b/src/views/Home/components/PostCard.tsx @@ -1,3 +1,4 @@ +import { useState, useRef, useEffect } from 'react' import { motion } from 'framer-motion' import { Heart, @@ -5,10 +6,13 @@ import { Share2, Bookmark, MoreHorizontal, - Play + Play, + Flag } from 'lucide-react' +import { toast } from 'sonner' import { Avatar } from '@/components/ui/Avatar' import { cn } from '@/lib/utils' +import FeedService from '@/services/FeedService' interface PostProps { post: any @@ -29,6 +33,28 @@ const PostCard = ({ onToggleBookmark, onToggleFollow }: PostProps) => { + const [menuOpen, setMenuOpen] = useState(false) + const menuRef = useRef(null) + + // Close menu when clicking outside + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + setMenuOpen(false) + } + } + if (menuOpen) { + document.addEventListener('mousedown', handleClickOutside) + } + return () => document.removeEventListener('mousedown', handleClickOutside) + }, [menuOpen]) + + const handleReport = async () => { + setMenuOpen(false) + await FeedService.reportPost(post.id) + toast.success('Post reported. Our team will review it shortly.') + } + return ( - + + {/* 3-dot menu */} +
+ + + {menuOpen && ( + + + + )} +
{/* Post Content */} diff --git a/src/views/Home/components/Sidebar.tsx b/src/views/Home/components/Sidebar.tsx index 3574eb1..c1c037f 100644 --- a/src/views/Home/components/Sidebar.tsx +++ b/src/views/Home/components/Sidebar.tsx @@ -5,13 +5,56 @@ import { Trophy } from 'lucide-react' import { Button } from '@/components/ui/Button' -import { motion } from 'framer-motion' +import { motion, useMotionValue, useSpring, useTransform } from 'framer-motion' +import { useEffect, useRef } from 'react' +import useSWR from 'swr' +import FeedService, { type Challenge } from '@/services/FeedService' + +// --------------------------------------------------------------------------- +// Animated entries count +// --------------------------------------------------------------------------- + +function AnimatedCount({ target }: { target: number }) { + const motionVal = useMotionValue(0) + const springVal = useSpring(motionVal, { stiffness: 60, damping: 18 }) + const displayVal = useTransform(springVal, v => { + if (v >= 1000) return `${(v / 1000).toFixed(1)}k` + return Math.round(v).toString() + }) + const hasAnimated = useRef(false) + + useEffect(() => { + if (!hasAnimated.current) { + hasAnimated.current = true + motionVal.set(target) + } + }, [motionVal, target]) + + return {displayVal} +} + +// Parse "1.2k" → 1200, "850" → 850 +function parseEntries(raw: string): number { + const trimmed = raw.trim().toLowerCase() + if (trimmed.endsWith('k')) return parseFloat(trimmed) * 1000 + return parseFloat(trimmed) || 0 +} + +// --------------------------------------------------------------------------- +// Sidebar +// --------------------------------------------------------------------------- interface SidebarProps { - challenges: any[] + challenges: Challenge[] } -const Sidebar = ({ challenges }: SidebarProps) => { +const Sidebar = ({ challenges: fallbackChallenges }: SidebarProps) => { + const { data: apiChallenges } = useSWR('challenges', () => FeedService.getChallenges()) + + const challenges: Challenge[] = (apiChallenges && apiChallenges.length > 0) + ? apiChallenges + : fallbackChallenges + return (
{/* Model Library Project Card */} @@ -37,7 +80,7 @@ const Sidebar = ({ challenges }: SidebarProps) => { {/* Trending Challenges */} - {/*
+
@@ -61,7 +104,9 @@ const Sidebar = ({ challenges }: SidebarProps) => { 0{index + 1}

#{challenge.name}

-

{challenge.entries} Poses

+

+ Poses +

@@ -74,7 +119,7 @@ const Sidebar = ({ challenges }: SidebarProps) => { -
*/} +
{/* Footer */}
diff --git a/src/views/Home/components/Stories.tsx b/src/views/Home/components/Stories.tsx index 64e192e..3d01b1b 100644 --- a/src/views/Home/components/Stories.tsx +++ b/src/views/Home/components/Stories.tsx @@ -1,12 +1,16 @@ import { Link } from 'react-router-dom' import { Zap } from 'lucide-react' import { motion } from 'framer-motion' +import useSWR from 'swr' +import FeedService from '@/services/FeedService' +import { Avatar } from '@/components/ui/Avatar' interface StoryItem { id: string | number type: string poseId?: string preview?: string + user?: { name: string; avatar: string } } interface StoriesProps { @@ -14,15 +18,39 @@ interface StoriesProps { } const Stories = ({ posts }: StoriesProps) => { - const recommendations = posts.filter(p => p.type === 'recommendation') + const { data: apiStories } = useSWR('stories', () => FeedService.getStories()) + + // Build story items: prefer API stories, fall back to first 4 posts with user data + const storyItems = (() => { + if (apiStories && apiStories.length > 0) { + return apiStories.map(s => ({ + id: s.id, + username: s.username, + avatar: s.avatar, + preview: s.preview, + })) + } + // Fallback: derive stories from posts that have user data + return posts + .filter(p => p.user) + .slice(0, 4) + .map(p => ({ + id: p.id, + username: p.user!.name, + avatar: p.user!.avatar, + preview: p.preview ?? p.type !== 'promo' ? undefined : undefined, + })) + })() return ( -
+
+ {/* "New Rig" shortcut — always first */} - +
@@ -30,27 +58,40 @@ const Stories = ({ posts }: StoriesProps) => {
- {recommendations.map((rec, index) => ( + {/* Story circles */} + {storyItems.map((story, index) => ( - -
-
- + ))}
diff --git a/src/views/Home/index.tsx b/src/views/Home/index.tsx index 0aeb085..85f2383 100644 --- a/src/views/Home/index.tsx +++ b/src/views/Home/index.tsx @@ -1,12 +1,19 @@ -import { useState } from 'react' +import { useState, useEffect, useRef, useCallback } from 'react' import { motion } from 'framer-motion' +import { Bell } from 'lucide-react' +import { toast } from 'sonner' +import useSWR from 'swr' import SearchBar from './components/SearchBar' import Stories from './components/Stories' import PostCard from './components/PostCard' import PromoCard from './components/PromoCard' import Sidebar from './components/Sidebar' +import FeedService from '@/services/FeedService' +import type { Challenge } from '@/@types/feed' -// --- Mock Data --- +// --------------------------------------------------------------------------- +// Static fallback data kept for Stories / first-render safety +// --------------------------------------------------------------------------- const COMMUNITY_POSTS = [ { id: 1, @@ -16,7 +23,7 @@ const COMMUNITY_POSTS = [ likes: 1240, comments: 45, caption: 'Just finished the cinematic standing pose! The lighting in PoseLab is insane! 🔥 #Minecraft #Render', - tags: ['#3DRendering', '#MinecraftArt'] + tags: ['#3DRendering', '#MinecraftArt'], }, { id: 2, @@ -26,7 +33,7 @@ const COMMUNITY_POSTS = [ likes: 890, comments: 12, caption: 'Experimenting with the bendable model. The joints feel so smooth now. 🧊', - tags: ['#BendableModel', '#Animation'] + tags: ['#BendableModel', '#Animation'], }, { id: 'promo-1', @@ -35,7 +42,7 @@ const COMMUNITY_POSTS = [ subtitle: 'Experience ultimate flexibility in your renders with our upgraded vertex-weight systems.', cta: 'Upgrade Rig', path: '/3d-viewer', - image: 'https://images.unsplash.com/photo-1614850523296-d8c1af93d400?q=80&w=2070&auto=format&fit=crop' + image: 'https://images.unsplash.com/photo-1614850523296-d8c1af93d400?q=80&w=2070&auto=format&fit=crop', }, { id: 3, @@ -45,14 +52,14 @@ const COMMUNITY_POSTS = [ likes: 2100, comments: 156, caption: 'Action sequence render! Ready for the battle. ⚔️', - tags: ['#CombatPose', '#Epic'] + tags: ['#CombatPose', '#Epic'], }, { id: 'pose-rec-1', type: 'recommendation', title: 'Try this: "The Hero Entry"', poseId: 'standing', - preview: '/poseLabsPose/standing.png' + preview: '/poseLabsPose/standing.png', }, { id: 4, @@ -62,40 +69,110 @@ const COMMUNITY_POSTS = [ likes: 560, comments: 8, caption: 'Starting small with basic walks. Practice makes perfect! 🚶‍♂️', - tags: ['#Beginner', '#PosePractice'] - } + tags: ['#Beginner', '#PosePractice'], + }, ] -const TRENDING_CHALLENGES = [ +const TRENDING_CHALLENGES: Challenge[] = [ { id: 1, name: 'SunlightShadows', entries: '1.2k' }, { id: 2, name: 'RigidDynamics', entries: '850' }, { id: 3, name: 'EpicCombat', entries: '2.4k' }, ] +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + const Home = () => { + // Interaction state const [likedPosts, setLikedPosts] = useState>(new Set()) const [bookmarkedPosts, setBookmarkedPosts] = useState>(new Set()) const [followedUsers, setFollowedUsers] = useState>(new Set()) + // Notifications badge + const [notifCount] = useState(3) + + // Infinite scroll state + const [page, setPage] = useState(1) + const [allPosts, setAllPosts] = useState([]) + const [hasMore, setHasMore] = useState(true) + + const sentinelRef = useRef(null) + + // SWR fetch for the current page + const { data: feedPage, isLoading } = useSWR( + ['feed', page], + () => FeedService.getFeed(page), + { revalidateOnFocus: false } + ) + + // Accumulate posts when a new page arrives + useEffect(() => { + if (!feedPage) return + if (feedPage.posts.length > 0) { + setAllPosts(prev => { + // Deduplicate by id + const existingIds = new Set(prev.map(p => p.id)) + const newPosts = feedPage.posts.filter(p => !existingIds.has(p.id)) + return [...prev, ...newPosts] + }) + } + if (feedPage.nextPage === null) { + setHasMore(false) + } + }, [feedPage]) + + // IntersectionObserver — increment page when sentinel enters viewport + const handleIntersect = useCallback( + (entries: IntersectionObserverEntry[]) => { + if (entries[0].isIntersecting && hasMore && !isLoading) { + setPage(prev => prev + 1) + } + }, + [hasMore, isLoading] + ) + + useEffect(() => { + const sentinel = sentinelRef.current + if (!sentinel) return + const observer = new IntersectionObserver(handleIntersect, { rootMargin: '200px' }) + observer.observe(sentinel) + return () => observer.disconnect() + }, [handleIntersect]) + + // Use accumulated posts or fall back to static data while first page loads + const displayPosts = allPosts.length > 0 ? allPosts : COMMUNITY_POSTS + + // Action handlers const toggleLike = (id: number | string) => { - const newLiked = new Set(likedPosts) - if (newLiked.has(id)) newLiked.delete(id) - else newLiked.add(id) - setLikedPosts(newLiked) + setLikedPosts(prev => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) } const toggleBookmark = (id: number | string) => { - const newBookmarks = new Set(bookmarkedPosts) - if (newBookmarks.has(id)) newBookmarks.delete(id) - else newBookmarks.add(id) - setBookmarkedPosts(newBookmarks) + setBookmarkedPosts(prev => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) } const toggleFollow = (username: string) => { - const newFollowed = new Set(followedUsers) - if (newFollowed.has(username)) newFollowed.delete(username) - else newFollowed.add(username) - setFollowedUsers(newFollowed) + setFollowedUsers(prev => { + const next = new Set(prev) + if (next.has(username)) next.delete(username) + else next.add(username) + return next + }) + } + + const handleBellClick = () => { + toast.info('Real-time notifications coming soon!') } return ( @@ -104,15 +181,31 @@ const Home = () => { {/* --- Main Feed Column --- */}
- {/* for fixed header - Adjusted for responsiveness */} -
-
-
- + {/* Fixed header background */} +
+
+
- + {/* Search bar + notification bell */} +
+ + {/* Notification bell — floats to the right of the search bar */} + +
+ {/* Discovery Hub / Stories */}

Discovery Hub

@@ -121,8 +214,9 @@ const Home = () => {
+ {/* Feed posts */}
- {COMMUNITY_POSTS.map((post) => { + {displayPosts.map((post) => { if (post.type === 'recommendation') return null if (post.type === 'promo') { @@ -144,33 +238,48 @@ const Home = () => { })}
- {/* Infinite Scroll/Loading Indicator */} + {/* Sentinel for IntersectionObserver */} +
+ + {/* Loading / end of feed indicator */}
- -
-
- -
- Decrypting community content -
+ {hasMore ? ( + <> + animate={{ + scale: [1, 1.1, 1], + rotate: [0, 180, 360] + }} + transition={{ + duration: 2, + repeat: Infinity, + ease: "easeInOut" + }} + className="relative w-14 h-14" + > +
+
+ +
+ Decrypting community content +
+ +
+
+ + ) : ( +
+
+ +
+ You've reached the end +

All community posts loaded

-
+ )}
diff --git a/src/views/users/LabUtils/CharacterViewer.tsx b/src/views/users/LabUtils/CharacterViewer.tsx index 371c3f5..165193a 100644 --- a/src/views/users/LabUtils/CharacterViewer.tsx +++ b/src/views/users/LabUtils/CharacterViewer.tsx @@ -1,24 +1,20 @@ -import { useState, useRef, useEffect, Suspense } from "react"; +import { useState, useRef, useEffect, Suspense, useCallback } from "react"; import { Canvas } from "@react-three/fiber"; import { OrbitControls, Environment, GizmoHelper, GizmoViewport } from "@react-three/drei"; +import * as THREE from "three"; +import { mutate } from "swr"; -import { MinecraftCharacter } from "./standard/MinecraftCharacter"; -import { BendableMinecraftCharacter } from "./bendable/BendableMinecraftCharacter"; -import { PoseControls as StandardPoseControls } from "./standard/PoseControls"; -import { PoseControls as BendablePoseControls } from "./bendable/PoseControls"; - -import STANDARD_POSES from "./standard/posePresets"; -import BENDABLE_POSES from "./bendable/posePresets"; import { NewMinecraftCharacter } from "./rigid-system/NewMinecraftCharacter"; import { RIGID_POSES, BENDABLE_RIGID_POSES } from "./rigid-system/posePresets"; +import { PoseControls } from "./rigid-system/PoseControls"; import { LightingControls, Light } from "./LightingControls"; import { LightRenderer } from "./LightRenderer"; import { RenderDialog, RenderSettings } from "./RenderDialog"; import { createHighQualityRender } from "./renderUtils"; -import { Camera, Upload, Download, RotateCcw, Lightbulb, Gpu, Box, Accessibility, Smile, LogOut, RotateCw } from "lucide-react"; +import { Camera, Upload, Download, Lightbulb, Gpu, Accessibility, Smile, LogOut, Menu, X } from "lucide-react"; import { toast } from "sonner"; -import * as THREE from "three"; +import { useSessionUser } from "@/store/authStore"; import { QualitySetting } from "./QualitySetting"; import { getQualityPreset } from "./qualitySettings"; import { useDeviceQuality } from "./hooks/useDeviceQuality"; @@ -32,19 +28,17 @@ export interface CharacterViewerProps { } export const CharacterViewer = ({ skinImage, onChangeSkinClick, pose }: CharacterViewerProps) => { - const [characterModel, setCharacterModel] = useState<'default' | 'bendable' | 'new_rigid' | 'new_bendable'>('default'); + const [characterModel, setCharacterModel] = useState<'rigid' | 'bendable'>('rigid'); - const activePresets = - characterModel === 'default' ? STANDARD_POSES : - characterModel === 'bendable' ? BENDABLE_POSES : - characterModel === 'new_rigid' ? RIGID_POSES : - BENDABLE_RIGID_POSES; + const activePresets = characterModel === 'rigid' ? RIGID_POSES : BENDABLE_RIGID_POSES; const defaultPose = activePresets.standing || Object.values(activePresets)[0]; const [currentPose, setCurrentPose] = useState(pose || defaultPose); const [selectedPreset, setSelectedPreset] = useState("standing"); const [openPanel, setOpenPanel] = useState(null); + const [sidebarOpen, setSidebarOpen] = useState(false); + const [isMobile, setIsMobile] = useState(false); const canvasRef = useRef(null); const sceneRef = useRef(null); const cameraRef = useRef(null); @@ -54,26 +48,58 @@ export const CharacterViewer = ({ skinImage, onChangeSkinClick, pose }: Characte { id: "light-2", type: "directional", position: [-10, 5, -5], color: "#ffffff", intensity: 0.3 }, ]); + const userId = useSessionUser((state) => state.user?.email ?? ''); + + const handleSaveSuccess = useCallback(() => { + if (userId) { + mutate(`creations-${userId}`); + } + }, [userId]); + const deviceQuality = useDeviceQuality(); const qualityPreset = getQualityPreset(deviceQuality.quality); + // Only enable heavy post-processing effects for high/ultra quality + const usePostProcessing = deviceQuality.quality === 'high' || deviceQuality.quality === 'ultra'; + + // Demand rendering: only run the render loop while the user is interacting + const [isInteracting, setIsInteracting] = useState(false); + const interactingTimeoutRef = useRef | null>(null); + + const handleInteractionStart = useCallback(() => { + if (interactingTimeoutRef.current) clearTimeout(interactingTimeoutRef.current); + setIsInteracting(true); + }, []); + + const handleInteractionEnd = useCallback(() => { + interactingTimeoutRef.current = setTimeout(() => setIsInteracting(false), 2000); + }, []); + + useEffect(() => { + return () => { + if (interactingTimeoutRef.current) clearTimeout(interactingTimeoutRef.current); + }; + }, []); + useEffect(() => { if (sceneRef.current) enhanceSceneMaterials(sceneRef.current, undefined, qualityPreset); }, [qualityPreset]); - const handleModelChange = (model: 'default' | 'bendable' | 'new_rigid' | 'new_bendable') => { + // Track mobile viewport + useEffect(() => { + const mq = window.matchMedia('(max-width: 767px)'); + const update = (e: MediaQueryListEvent | MediaQueryList) => setIsMobile(e.matches); + update(mq); + mq.addEventListener('change', update); + return () => mq.removeEventListener('change', update); + }, []); + + const handleModelChange = (model: 'rigid' | 'bendable') => { if (model === characterModel) return; - // Get the correct presets for the NEW model - const newPresets = - model === 'default' ? STANDARD_POSES : - model === 'bendable' ? BENDABLE_POSES : - model === 'new_rigid' ? RIGID_POSES : - BENDABLE_RIGID_POSES; + const newPresets = model === 'rigid' ? RIGID_POSES : BENDABLE_RIGID_POSES; const newDefault = newPresets.standing || Object.values(newPresets)[0]; - // Update everything in one batch to ensure they stay in sync - // This is critical to prevent passing a Bendable pose to the Standard renderer setCharacterModel(model); setCurrentPose(newDefault); setSelectedPreset("standing"); @@ -81,17 +107,23 @@ export const CharacterViewer = ({ skinImage, onChangeSkinClick, pose }: Characte toast(`Switched to ${newDefault.poseMeta?.name || "Default"} for ${model} model`); }; - const handlePoseChange = (bodyPart: string, axis: string, value: number) => { - setCurrentPose((prev: any) => ({ - ...prev, - poseConfig: { - ...prev.poseConfig, - [bodyPart]: { - ...prev.poseConfig[bodyPart], - [axis]: value, + const handlePoseChange = (partName: string, axis: 'x' | 'y' | 'z', valueDeg: number) => { + setCurrentPose((prev: any) => { + const prevEuler: THREE.Euler = prev.poseConfig.rotations[partName] || new THREE.Euler(0, 0, 0); + const newEuler = new THREE.Euler(prevEuler.x, prevEuler.y, prevEuler.z, prevEuler.order); + newEuler[axis] = (valueDeg * Math.PI) / 180; + + return { + ...prev, + poseConfig: { + ...prev.poseConfig, + rotations: { + ...prev.poseConfig.rotations, + [partName]: newEuler, + }, }, - }, - })); + }; + }); setSelectedPreset("custom"); }; @@ -164,50 +196,82 @@ export const CharacterViewer = ({ skinImage, onChangeSkinClick, pose }: Characte ); + const sidebarContent = ( + <> + { setOpenPanel(openPanel === "poses" ? "poseControls" : "poses"); setSidebarOpen(false); }} + /> + { setOpenPanel("expressions"); setSidebarOpen(false); }} + /> + { setOpenPanel("lighting"); setSidebarOpen(false); }} + /> + { setOpenPanel("quality"); setSidebarOpen(false); }} + /> + +
+ + +
+ + ); + return (
- {/* Sidebar */} -
); }; diff --git a/src/views/users/LabUtils/RenderDialog.tsx b/src/views/users/LabUtils/RenderDialog.tsx index 559b5a5..f4b0977 100644 --- a/src/views/users/LabUtils/RenderDialog.tsx +++ b/src/views/users/LabUtils/RenderDialog.tsx @@ -13,8 +13,10 @@ import { Switch } from "@/components/ui/switch"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Input } from "@/components/ui/Input"; import { Progress } from "@/components/ui/progress"; -import { Download, Loader2, X, Sparkles } from "lucide-react"; +import { Download, Loader2, X, Sparkles, BookmarkPlus } from "lucide-react"; import { motion, AnimatePresence } from "framer-motion"; +import { toast } from "sonner"; +import CreationService from "@/services/CreationService"; export interface RenderSettings { width: number; @@ -27,6 +29,9 @@ interface RenderDialogProps { open: boolean; onOpenChange: (open: boolean) => void; onRender: (settings: RenderSettings, onProgress: (progress: number) => void) => Promise<{ blob: Blob; previewUrl: string }>; + poseSnapshot?: Record; + skinUrl?: string; + onSaveSuccess?: () => void; } const PRESET_SIZES = [ @@ -37,7 +42,7 @@ const PRESET_SIZES = [ { label: "Custom", value: "custom" }, ]; -export const RenderDialog = ({ open, onOpenChange, onRender }: RenderDialogProps) => { +export const RenderDialog = ({ open, onOpenChange, onRender, poseSnapshot, skinUrl, onSaveSuccess }: RenderDialogProps) => { const [sizePreset, setSizePreset] = useState("1920x1080"); const [customWidth, setCustomWidth] = useState(1920); const [customHeight, setCustomHeight] = useState(1080); @@ -47,6 +52,8 @@ export const RenderDialog = ({ open, onOpenChange, onRender }: RenderDialogProps const [renderProgress, setRenderProgress] = useState(0); const [previewUrl, setPreviewUrl] = useState(null); const [renderedBlob, setRenderedBlob] = useState(null); + const [lastSettings, setLastSettings] = useState(null); + const [isSaving, setIsSaving] = useState(false); const handleRender = async () => { let width = customWidth; @@ -69,6 +76,7 @@ export const RenderDialog = ({ open, onOpenChange, onRender }: RenderDialogProps setRenderProgress(0); setPreviewUrl(null); setRenderedBlob(null); + setLastSettings(settings); try { const result = await onRender(settings, setRenderProgress); @@ -113,9 +121,29 @@ export const RenderDialog = ({ open, onOpenChange, onRender }: RenderDialogProps setRenderProgress(0); }; + const handleSaveToGallery = async () => { + if (!previewUrl) return; + setIsSaving(true); + try { + await CreationService.saveRender({ + imageDataUrl: previewUrl, + poseSnapshot: poseSnapshot ?? {}, + skinUrl: skinUrl, + renderSettings: lastSettings ? { ...lastSettings } : {}, + }); + toast.success('Render saved to gallery!'); + onSaveSuccess?.(); + handleClose(); + } catch { + toast.error('Failed to save render'); + } finally { + setIsSaving(false); + } + }; + return ( - + {open && (
@@ -152,7 +180,7 @@ export const RenderDialog = ({ open, onOpenChange, onRender }: RenderDialogProps style={{ maxHeight: "500px", objectFit: "contain" }} />
-
+
+
diff --git a/src/views/users/LabUtils/bendable/BendableMinecraftCharacter.tsx b/src/views/users/LabUtils/bendable/BendableMinecraftCharacter.tsx index 17fe250..6a4abd4 100644 --- a/src/views/users/LabUtils/bendable/BendableMinecraftCharacter.tsx +++ b/src/views/users/LabUtils/bendable/BendableMinecraftCharacter.tsx @@ -1,3 +1,4 @@ +// @deprecated — Use rigid-system/NewMinecraftCharacter instead. Kept for reference only. import { useMemo, useRef, useEffect, useCallback } from "react"; import * as THREE from "three"; import { createPortal } from "@react-three/fiber"; diff --git a/src/views/users/LabUtils/hooks/useDeviceQuality.ts b/src/views/users/LabUtils/hooks/useDeviceQuality.ts index 73e1c2b..337055f 100644 --- a/src/views/users/LabUtils/hooks/useDeviceQuality.ts +++ b/src/views/users/LabUtils/hooks/useDeviceQuality.ts @@ -1,89 +1,129 @@ import { useState, useEffect } from "react"; import { QualityLevel } from "./../qualitySettings"; +/** + * Synchronously estimates quality from CPU/memory hints (no WebGL context needed). + * Used as the initial value to avoid a quality-drop flash on first render. + */ +const estimateInitialQuality = (): QualityLevel => { + // Check for saved user preference first + try { + const saved = localStorage.getItem("userQualityPreference"); + if (saved) return saved as QualityLevel; + } catch { + // localStorage may be unavailable in some contexts + } + + const cores = navigator.hardwareConcurrency ?? 4; + const memory = (navigator as any).deviceMemory as number | undefined; // GB, if available + + const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test( + navigator.userAgent + ); + + if (isMobile) { + return memory && memory >= 4 ? "medium" : "low"; + } + + if (cores >= 8 && memory && memory >= 8) return "high"; + if (cores >= 4 && memory && memory >= 4) return "medium"; + if (cores >= 4) return "medium"; + return "low"; +}; + /** * Detects device capabilities and suggests optimal quality level * Can be overridden by user preference */ export const useDeviceQuality = () => { - const [autoDetectedQuality, setAutoDetectedQuality] = useState("medium"); - const [userQuality, setUserQuality] = useState(null); + // Initialise synchronously so the very first render already uses the right quality + const [autoDetectedQuality, setAutoDetectedQuality] = useState( + () => estimateInitialQuality() + ); + const [userQuality, setUserQuality] = useState(() => { + try { + const saved = localStorage.getItem("userQualityPreference"); + return saved ? (saved as QualityLevel) : null; + } catch { + return null; + } + }); const [fps, setFps] = useState(60); - + useEffect(() => { + // Refine quality estimate using WebGL info (runs asynchronously after mount) const detectQuality = (): QualityLevel => { - // GPU capability check const canvas = document.createElement("canvas"); const gl = canvas.getContext("webgl2") || canvas.getContext("webgl"); - + if (!gl) return "low"; - + const renderer = gl.getParameter(gl.RENDERER); const vendor = gl.getParameter(gl.VENDOR); - + // Check max texture size (proxy for GPU power) const maxTextureSize = gl.getParameter(gl.MAX_TEXTURE_SIZE); - + // Memory check (if available) const memory = (performance as any).memory?.jsHeapSizeLimit || 0; // eslint-disable-line - + // Device type detection const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test( navigator.userAgent ); - + // RTX or high-end GPU detection const isHighEndGPU = /NVIDIA|GeForce RTX|Radeon RX|Apple M[0-9]|Metal/i.test( renderer + vendor ); - + // Quality determination logic if (isMobile) { return memory > 2e9 ? "medium" : "low"; } - + if (isHighEndGPU && maxTextureSize >= 16384 && memory > 4e9) { return "ultra"; } - + if (maxTextureSize >= 8192 && memory > 4e9) { return "high"; } - + if (maxTextureSize >= 4096 && memory > 2e9) { return "medium"; } - + return "low"; }; - + const quality = detectQuality(); setAutoDetectedQuality(quality); - - // Load user preference if exists + + // Load user preference if exists (re-confirm after mount) const savedQuality = localStorage.getItem("userQualityPreference"); if (savedQuality) { setUserQuality(savedQuality as QualityLevel); } }, []); - + const setQuality = (level: QualityLevel) => { setUserQuality(level); localStorage.setItem("userQualityPreference", level); }; - + const resetToAuto = () => { setUserQuality(null); localStorage.removeItem("userQualityPreference"); }; - + // Adaptive quality based on FPS (optional feature) const updateFPS = (currentFPS: number) => { setFps(currentFPS); }; - + const activeQuality = userQuality || autoDetectedQuality; - + return { quality: activeQuality, autoDetectedQuality, @@ -93,4 +133,4 @@ export const useDeviceQuality = () => { fps, updateFPS, }; -}; \ No newline at end of file +}; diff --git a/src/views/users/LabUtils/rigid-system/NewMinecraftCharacter.tsx b/src/views/users/LabUtils/rigid-system/NewMinecraftCharacter.tsx index 4b0a7a5..5a2abeb 100644 --- a/src/views/users/LabUtils/rigid-system/NewMinecraftCharacter.tsx +++ b/src/views/users/LabUtils/rigid-system/NewMinecraftCharacter.tsx @@ -10,6 +10,36 @@ interface NewMinecraftCharacterProps { bendable?: boolean; } +/** + * Module-level cache for CanvasTextures keyed by skin image src. + * Avoids re-creating identical textures on every re-render and across + * multiple character instances that share the same skin. + * Limited to 5 entries to prevent unbounded memory growth. + */ +const textureCache = new Map(); + +const MAX_TEXTURE_CACHE_SIZE = 5; + +const getCachedTexture = (cacheKey: string, factory: () => THREE.CanvasTexture): THREE.CanvasTexture => { + const cached = textureCache.get(cacheKey); + if (cached) return cached; + + const texture = factory(); + + // Evict oldest entry when cache is full + if (textureCache.size >= MAX_TEXTURE_CACHE_SIZE) { + const oldestKey = textureCache.keys().next().value; + if (oldestKey !== undefined) { + const oldTexture = textureCache.get(oldestKey); + oldTexture?.dispose(); + textureCache.delete(oldestKey); + } + } + + textureCache.set(cacheKey, texture); + return texture; +}; + export const NewMinecraftCharacter: React.FC = ({ skinImage, pose, bendable = false }) => { // 1. Create Rig Definition const rigDefinition = useMemo(() => createMinecraftRig(bendable), [bendable]); @@ -33,29 +63,35 @@ export const NewMinecraftCharacter: React.FC = ({ sk return faces.map(face => { const [fx, fy, fw, fh] = face.rect; - const canvas = document.createElement('canvas'); - // Scale for crisp pixels - const scale = 4; - canvas.width = fw * scale; - canvas.height = fh * scale; - const ctx = canvas.getContext('2d'); - - if (ctx) { - ctx.imageSmoothingEnabled = false; - if (skinImage) { - ctx.drawImage(skinImage, fx, fy, fw, fh, 0, 0, fw * scale, fh * scale); - } else { - ctx.fillStyle = '#777'; - ctx.fillRect(0, 0, canvas.width, canvas.height); + // Build a stable cache key from skin src + face coordinates + const cacheKey = `${skinImage?.src ?? 'noskin'}|${fx},${fy},${fw},${fh}`; + + const tex = getCachedTexture(cacheKey, () => { + const canvas = document.createElement('canvas'); + // Scale for crisp pixels + const scale = 4; + canvas.width = fw * scale; + canvas.height = fh * scale; + const ctx = canvas.getContext('2d'); + + if (ctx) { + ctx.imageSmoothingEnabled = false; + if (skinImage) { + ctx.drawImage(skinImage, fx, fy, fw, fh, 0, 0, fw * scale, fh * scale); + } else { + ctx.fillStyle = '#777'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + } } - } - - const tex = new THREE.CanvasTexture(canvas); - tex.colorSpace = THREE.SRGBColorSpace; - tex.magFilter = THREE.NearestFilter; - tex.minFilter = THREE.NearestFilter; - tex.generateMipmaps = false; - tex.flipY = true; // Important for mapping to box faces + + const t = new THREE.CanvasTexture(canvas); + t.colorSpace = THREE.SRGBColorSpace; + t.magFilter = THREE.NearestFilter; + t.minFilter = THREE.NearestFilter; + t.generateMipmaps = false; + t.flipY = true; // Important for mapping to box faces + return t; + }); return new THREE.MeshStandardMaterial({ map: tex, diff --git a/src/views/users/LabUtils/rigid-system/PoseControls.tsx b/src/views/users/LabUtils/rigid-system/PoseControls.tsx new file mode 100644 index 0000000..e610e05 --- /dev/null +++ b/src/views/users/LabUtils/rigid-system/PoseControls.tsx @@ -0,0 +1,149 @@ +import * as THREE from 'three'; +import { Label } from "@/components/ui/label"; +import { Slider } from "@/components/ui/slider"; +import { RigState } from './types'; + +interface PoseControlsProps { + pose: { poseConfig: RigState }; + onPoseChange: (partName: string, axis: 'x' | 'y' | 'z', valueDeg: number) => void; +} + +// Groups define render order and visual color coding +const PART_GROUPS: { label: string; color: string; keys: string[] }[] = [ + { + label: "Head", + color: "text-yellow-400", + keys: ["head"], + }, + { + label: "Body", + color: "text-orange-400", + keys: ["body"], + }, + { + label: "Arms", + color: "text-blue-400", + keys: ["leftArm", "rightArm", "leftUpperArm", "leftLowerArm", "rightUpperArm", "rightLowerArm"], + }, + { + label: "Legs", + color: "text-green-400", + keys: ["leftLeg", "rightLeg", "leftUpperLeg", "leftLowerLeg", "rightUpperLeg", "rightLowerLeg"], + }, +]; + +const PART_LABELS: Record = { + head: "Head", + body: "Body", + leftArm: "Left Arm", + rightArm: "Right Arm", + leftUpperArm: "Left Upper Arm", + leftLowerArm: "Left Forearm", + rightUpperArm: "Right Upper Arm", + rightLowerArm: "Right Forearm", + leftLeg: "Left Leg", + rightLeg: "Right Leg", + leftUpperLeg: "Left Thigh", + leftLowerLeg: "Left Shin", + rightUpperLeg: "Right Thigh", + rightLowerLeg: "Right Shin", +}; + +const PART_COLORS: Record = { + head: "text-yellow-400", + body: "text-orange-400", + leftArm: "text-blue-400", + rightArm: "text-purple-400", + leftUpperArm: "text-blue-400", + leftLowerArm: "text-cyan-400", + rightUpperArm: "text-purple-400", + rightLowerArm: "text-pink-400", + leftLeg: "text-green-400", + rightLeg: "text-indigo-400", + leftUpperLeg: "text-green-400", + leftLowerLeg: "text-lime-400", + rightUpperLeg: "text-indigo-400", + rightLowerLeg: "text-violet-400", +}; + +const axes: { key: 'x' | 'y' | 'z'; label: string; color: string }[] = [ + { key: 'x', label: 'X (Pitch)', color: 'text-red-400' }, + { key: 'y', label: 'Y (Yaw)', color: 'text-green-400' }, + { key: 'z', label: 'Z (Roll)', color: 'text-blue-400' }, +]; + +const radToDeg = (rad: number) => Math.round((rad * 180) / Math.PI); + +export const PoseControls = ({ pose, onPoseChange }: PoseControlsProps) => { + const rotations = pose.poseConfig.rotations; + const availableKeys = Object.keys(rotations); + + return ( +
+

+ Manual Joint Manipulation +

+ + {PART_GROUPS.map((group) => { + // Only render parts that actually exist in current pose + const groupParts = group.keys.filter((k) => availableKeys.includes(k)); + if (groupParts.length === 0) return null; + + return ( +
+

+ {group.label} +

+ +
+ {groupParts.map((partName) => { + const euler = rotations[partName] as THREE.Euler; + const color = PART_COLORS[partName] || "text-gray-400"; + const label = PART_LABELS[partName] || partName; + + return ( +
+

+ {label} +

+ +
+ {axes.map((axis) => { + const deg = radToDeg(euler[axis.key]); + return ( +
+
+ + + {deg}° + +
+ + onPoseChange(partName, axis.key, value) + } + min={-180} + max={180} + step={5} + className="w-full" + /> +
+ ); + })} +
+
+ ); + })} +
+
+ ); + })} +
+ ); +}; diff --git a/src/views/users/LabUtils/rigid-system/posePresets.ts b/src/views/users/LabUtils/rigid-system/posePresets.ts index fbf379f..96a3535 100644 --- a/src/views/users/LabUtils/rigid-system/posePresets.ts +++ b/src/views/users/LabUtils/rigid-system/posePresets.ts @@ -42,7 +42,59 @@ export const RIGID_POSES: Record = { @@ -96,5 +148,39 @@ export const BENDABLE_RIGID_POSES: Record ({ + opacity: 1, + y: 0, + transition: { delay: i * 0.08, duration: 0.35, ease: 'easeOut' }, + }), +} const CreatorSpace = () => { + const { user } = useSessionUser() + const isVerifiedCreator = user?.authority?.includes('creator') + + // Publish form state + const [poseName, setPoseName] = useState('') + const [description, setDescription] = useState('') + const [tags, setTags] = useState('') + + // Creator application state + const [applied, setApplied] = useState(false) + + const handleUnpublish = () => { + toast.info('Coming soon') + } + + const handlePublish = (e: React.FormEvent) => { + e.preventDefault() + toast.info('Pose publishing coming soon — connect to the PoseLab editor first!') + } + + const handleApply = () => { + setApplied(true) + toast.success('Application submitted! We\'ll review within 48 hours.') + } + return ( - } - /> +
+ + {/* Page Header */} +
+

+ + Creator Space +

+

Your hub for managing and publishing poses.

+
+ + {/* ── Section 1: Stats Bar ── */} +
+ {STATS.map((stat, i) => { + const Icon = stat.icon + return ( + +
+ {stat.label} + +
+
{stat.value}
+
{stat.trend} this week
+
+ ) + })} +
+ + {/* ── Section 2: Published Poses ── */} +
+

+ + Published Poses +

+ +
+ {SAMPLE_POSES.map((pose, i) => ( + + {/* Preview image */} +
+ {pose.name} +
+ + {/* Card body */} +
+
+ {pose.name} +
+ + +
+
+ + {/* Stats row */} +
+ + + {pose.views.toLocaleString()} + + + + {pose.likes.toLocaleString()} + +
+ + {/* Tags */} +
+ {pose.tags.map(tag => ( + + {tag} + + ))} +
+
+
+ ))} +
+
+ + {/* ── Section 3: Publish New Pose ── */} + +

+ + Publish New Pose +

+ +
+
+ + setPoseName(e.target.value)} + className="bg-gray-950 border-white/10 text-white placeholder:text-gray-600 focus-visible:ring-primary/50" + /> +
+ +
+ +