diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8ee2e01c9003b0684a0697eecc572ed050cbab6d..47e25a2eeacfa79e53afb1a488994d3cd11ab753 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,7 @@ on: run_integration: description: 'Run integration tests (requires HuggingFace download)' required: false - default: 'false' + default: false type: boolean jobs: @@ -106,3 +106,112 @@ jobs: run: uv run pytest -m integration --timeout=600 env: HF_HOME: /tmp/hf_cache + + # Frontend Jobs + frontend-lint: + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: frontend/package-lock.json + + - run: npm ci + - run: npm run lint + + frontend-typecheck: + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: frontend/package-lock.json + + - run: npm ci + - run: npx tsc --noEmit + + frontend-test: + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: frontend/package-lock.json + + - run: npm ci + - run: npm run test:coverage + + - uses: codecov/codecov-action@v4 + with: + files: frontend/coverage/coverage-final.json + flags: frontend + fail_ci_if_error: false + token: ${{ secrets.CODECOV_TOKEN }} + + frontend-e2e: + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: frontend/package-lock.json + + - run: npm ci + - run: npx playwright install --with-deps chromium + + - run: npm run test:e2e + + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: playwright-report + path: frontend/playwright-report/ + retention-days: 7 + + frontend-build: + runs-on: ubuntu-latest + needs: [frontend-lint, frontend-typecheck, frontend-test] + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: frontend/package-lock.json + + - run: npm ci + - run: npm run build + + - uses: actions/upload-artifact@v4 + with: + name: frontend-dist + path: frontend/dist/ + retention-days: 7 diff --git a/docs/specs/00-context.md b/docs/specs/00-context.md deleted file mode 100644 index 1c925edd1b17b3210166e9b6a745f46a01c55283..0000000000000000000000000000000000000000 --- a/docs/specs/00-context.md +++ /dev/null @@ -1,214 +0,0 @@ -# context: stroke-deepisles-demo - -> **Disclaimer**: This software is for research and demonstration purposes only. Not for clinical use. - -## overview - -This document explains **why** we're building `stroke-deepisles-demo` and the architectural context that informs our design decisions. - -## the problem we're solving - -We want to demonstrate an end-to-end neuroimaging inference pipeline: - -``` -CURRENT (Phase 1A): - Local NIfTI files (extracted from ISLES24-MR-Lite ZIPs) - ↓ - File-based loader (parse BIDS filenames) - ↓ - DeepISLES Docker (stroke segmentation) - ↓ - NiiVue visualization (Gradio Space) - -FUTURE (Phase 1C-D): - HuggingFace Hub (properly uploaded dataset) - ↓ - Tobias's datasets fork (BIDS loader + Nifti feature) - ↓ - DeepISLES Docker (stroke segmentation) - ↓ - NiiVue visualization (Gradio Space) -``` - -This showcases that: -1. Neuroimaging data can be loaded from local BIDS-named files (NOW) -2. Neuroimaging data can be consumed from HF Hub with proper BIDS/NIfTI support (FUTURE) -3. Clinical-grade models can run via Docker as black boxes -4. Results can be visualized interactively in a browser - -## critical discovery (2025-12-04) - -**The original ISLES24-MR-Lite dataset is NOT properly uploaded to HuggingFace.** - -It's just raw ZIP files dumped on HF, not a proper Dataset with parquet/Arrow format. This means `load_dataset()` fails. See `data/discovery/isles24_schema_report.txt` for full details. - -**Workaround**: We extracted the ZIPs locally to `data/isles24/` (git-ignored) and will implement a file-based loader first. Later, we'll re-upload properly and verify full HF consumption. - -## why we need tobias's datasets fork - -As of December 2025, the official `huggingface/datasets` library has **partial** NIfTI support but lacks critical features for neuroimaging workflows. - -### what's merged upstream - -| PR | Author | Status | Description | -|----|--------|--------|-------------| -| [#7874](https://github.com/huggingface/datasets/pull/7874) | CloseChoice (Tobias) | Merged Nov 21 | NIfTI visualization support | -| [#7878](https://github.com/huggingface/datasets/pull/7878) | CloseChoice (Tobias) | Merged Nov 27 | Replace papaya with NiiVue | - -### what's NOT merged (and why we need the fork) - -| PR | Author | Status | Description | -|----|--------|--------|-------------| -| [#7886](https://github.com/huggingface/datasets/pull/7886) | The-Obstacle-Is-The-Way | Open | **BIDS dataset loader** - `load_dataset('bids', ...)` | -| [#7887](https://github.com/huggingface/datasets/pull/7887) | The-Obstacle-Is-The-Way | Open | **NIfTI lazy loading fix** - use `dataobj` not `get_fdata()` | -| [#7892](https://github.com/huggingface/datasets/pull/7892) | CloseChoice (Tobias) | Open | **NIfTI encoding for lazy upload** - fixes Arrow serialization | - -The fork branch bundles all these features: -``` -https://github.com/CloseChoice/datasets/tree/feat/bids-loader-streaming-upload-fix -``` - -We pin to this branch until upstream merges the PRs. - -## key components - -### 1. data source: ISLES24-MR-Lite - -- **HF Dataset**: [YongchengYAO/ISLES24-MR-Lite](https://huggingface.co/datasets/YongchengYAO/ISLES24-MR-Lite) (**BROKEN** - raw ZIPs, not proper dataset) -- **Local extracted**: `data/isles24/` (git-ignored) -- **Content**: 149 acute stroke MRI cases with DWI, ADC, and manual infarct masks -- **Origin**: Subset of ISLES 2024 challenge data -- **Why suitable**: DeepISLES was trained on ISLES 2022, so ISLES24 is an **external** test set (no data leakage) - -**File structure** (after extraction): -``` -data/isles24/ -├── Images-DWI/sub-stroke{XXXX}_ses-02_dwi.nii.gz # 149 files -├── Images-ADC/sub-stroke{XXXX}_ses-02_adc.nii.gz # 149 files -└── Masks/sub-stroke{XXXX}_ses-02_lesion-msk.nii.gz # 149 files -``` - -**Schema reference**: `data/discovery/isles24_schema_report.txt` - -### 2. model: DeepISLES - -- **Paper**: Nature Communications 2025 - "DeepISLES: A clinically validated ischemic stroke segmentation model" -- **GitHub**: [ezequieldlrosa/DeepIsles](https://github.com/ezequieldlrosa/DeepIsles) -- **Docker**: `isleschallenge/deepisles` -- **Inputs**: DWI + ADC (required), FLAIR (required for ensemble, optional for fast mode) -- **Output**: 3D binary lesion mask (NIfTI) -- **Mode**: `fast=True` runs **SEALS only** (the ISLES'22 challenge winner) - -#### Why we use `fast=True` (SEALS-only mode) - -DeepISLES is an ensemble of 3 models from the ISLES'22 challenge: - -| Model | Based On | Inputs Required | Notes | -|-------|----------|-----------------|-------| -| **SEALS** | nnUNet | DWI + ADC | 🏆 **ISLES'22 Winner** - runs in `--fast` mode | -| NVAUTO | MONAI Auto3dseg | DWI + ADC + FLAIR | Requires FLAIR | -| SWAN | FACTORIZER | DWI + ADC + FLAIR | Requires FLAIR | - -**Key insight**: ISLES24-MR-Lite contains only DWI + ADC (no FLAIR). Therefore: -- `--fast True` → Runs SEALS only → **Perfect match** for our dataset -- `--fast False` → Would try to run all 3 models → NVAUTO/SWAN would fail without FLAIR - -This is **not a downgrade**. SEALS won the ISLES'22 challenge and is state-of-the-art for stroke lesion segmentation using DWI+ADC alone. - -#### Scientific validity: External validation with zero data leakage - -| Dataset | Year | Used For | -|---------|------|----------| -| **ISLES 2022** | 2022 | SEALS training data (250 cases) | -| **ISLES 2024** | 2024 | Our test data (149 cases from MR-Lite) | - -- Different patient cohorts (2 years apart, different hospitals) -- SEALS has **never seen** ISLES24 patients -- We have ground truth masks → can validate predictions -- This constitutes a legitimate **external validation study** - -### 3. visualization: NiiVue - -- **Library**: [niivue/niivue](https://github.com/niivue/niivue) -- **Type**: WebGL2-based neuroimaging viewer -- **Formats**: Native NIfTI support, overlays, multiplanar views -- **Integration**: Via Gradio custom HTML component or iframe - -### 4. UI framework: Gradio 5 - -- **Version**: Gradio 5.x (latest as of Dec 2025) -- **Features**: SSR for fast loading, improved components, WebRTC support -- **Deployment**: Hugging Face Spaces - -## architecture diagram - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ stroke-deepisles-demo │ -├─────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ -│ │ data/ │ │ inference/ │ │ ui/ │ │ -│ │ │ │ │ │ │ │ -│ │ - loader │───▶│ - docker │───▶│ - gradio │ │ -│ │ - adapter │ │ - wrapper │ │ - niivue │ │ -│ │ - staging │ │ - pipeline │ │ - viewer │ │ -│ └──────────────┘ └──────────────┘ └──────────────┘ │ -│ │ │ │ │ -│ ▼ ▼ ▼ │ -│ ┌──────────────────────────────────────────────────────┐ │ -│ │ core/ │ │ -│ │ - config (pydantic-settings) │ │ -│ │ - types (dataclasses, TypedDicts) │ │ -│ │ - exceptions │ │ -│ └──────────────────────────────────────────────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────────┘ - │ │ │ - ▼ ▼ ▼ - ┌──────────┐ ┌──────────┐ ┌──────────┐ - │ HF Hub │ │ Docker │ │ Browser │ - │ datasets │ │ Engine │ │ WebGL2 │ - └──────────┘ └──────────┘ └──────────┘ -``` - -## design principles - -1. **Vertical slices**: Each phase delivers runnable functionality -2. **TDD**: Tests written before implementation -3. **Type safety**: Full type hints, mypy/pyright strict mode -4. **Separation of concerns**: Data, inference, and UI are independent modules -5. **Docker as black box**: We don't reimplement DeepISLES, we call it -6. **Graceful degradation**: Mock Docker for tests, fallback viewers if NiiVue fails - -## reference repositories - -These are cloned locally (without git linkages) for reference: - -| Directory | Source | Purpose | -|-----------|--------|---------| -| `_reference_repos/datasets-tobias-bids-fork/` | CloseChoice/datasets@feat/bids-loader-streaming-upload-fix | BIDS loader + NIfTI lazy loading | -| `_reference_repos/arc-aphasia-bids/` | The-Obstacle-Is-The-Way/arc-aphasia-bids | BIDS upload patterns (reference only) | -| `_reference_repos/DeepIsles/` | ezequieldlrosa/DeepIsles | DeepISLES CLI interface reference | -| `_reference_repos/bids-neuroimaging-space/` | [TobiasPitters/bids-neuroimaging](https://huggingface.co/spaces/TobiasPitters/bids-neuroimaging) | **Working NiiVue + FastAPI implementation** | - -### key reference: tobias's bids-neuroimaging space - -This is the most important reference for Phase 4 (UI). It demonstrates: - -1. **NiiVue working in HF Spaces** - Proof that WebGL2 viewer works in production -2. **FastAPI + raw HTML approach** - Clean, no Gradio overhead for viewer -3. **Base64 data URLs for NIfTI** - `data:application/octet-stream;base64,{b64}` -4. **NiiVue CDN loading** - `https://unpkg.com/@niivue/niivue@0.57.0/dist/index.js` -5. **Multiplanar + 3D rendering** - `setSliceType(sliceTypeMultiplanar)` + `setMultiplanarLayout(2)` - -Key file: `main.py` (~485 lines) - complete working implementation. - -## sources - -- [uv project configuration](https://docs.astral.sh/uv/concepts/projects/config/) -- [Python packaging guide - pyproject.toml](https://packaging.python.org/en/latest/guides/writing-pyproject-toml/) -- [Real Python - Managing projects with uv](https://realpython.com/python-uv/) -- [Gradio 5 announcement](https://huggingface.co/blog/gradio-5) -- [NiiVue GitHub](https://github.com/niivue/niivue) -- [Gradio custom HTML components](https://www.gradio.app/guides/custom_HTML_components) diff --git a/docs/specs/24-bug-gradio-webgl-analysis.md b/docs/specs/24-bug-gradio-webgl-analysis.md deleted file mode 100644 index 557ba62590f3546e37478c86a3e3fef476c9349a..0000000000000000000000000000000000000000 --- a/docs/specs/24-bug-gradio-webgl-analysis.md +++ /dev/null @@ -1,156 +0,0 @@ -# Bug #24: Gradio + WebGL/NiiVue Root Cause Analysis - -**Date:** 2025-12-10 -**Status:** ALL `gr.HTML` HACKS FAILED - Custom Component Required -**Issue:** HF Spaces stuck on "Loading..." forever -**Root Cause:** The `gr.HTML` + `js_on_load` + async `import()` pattern blocks Svelte hydration -**Note:** Gradio CAN do WebGL via Custom Components (proven by gradio-litmodel3d) -**Solution:** Build Gradio Custom Component (see spec #28) - ---- - -## CONFIRMED: All gr.HTML Hacks Have Failed - -| Attempt | Date | Result | -|---------|------|--------| -| CDN import in js_on_load | Dec 9 | FAILED - CSP blocks external imports | -| Vendored + dynamic import() in js_on_load | Dec 9 | FAILED - Blocks Svelte hydration | -| head_paths with loader HTML | Dec 9 | FAILED - Same hydration issue | -| head= with inline import() | Dec 10 | **FAILED** - Confirmed DOA | - -**There is no hack that works.** The only path forward is spec #28 (Gradio Custom Component). - ---- - -## Why Are We Using Gradio? - -**What Gradio provides:** -- Quick ML demo UIs with Python only (no frontend code needed) -- Built-in components: file upload, sliders, dropdowns, image display -- Easy deployment to HuggingFace Spaces -- Handles backend/frontend communication automatically - -**What Gradio does NOT provide:** -- Native support for NIfTI/DICOM medical imaging (closed as "not planned" - [Issue #4511](https://github.com/gradio-app/gradio/issues/4511)) -- Native WebGL canvas component (closed as "not planned" - [Issue #7649](https://github.com/gradio-app/gradio/issues/7649)) -- Clean way to embed custom WebGL libraries like NiiVue - ---- - -## The Root Cause: We're Fighting `gr.HTML`, Not Gradio - -### What We're Trying To Do -Embed NiiVue (a WebGL2 library) into `gr.HTML` using JavaScript. - -### Why `gr.HTML` + JavaScript Doesn't Work -1. **`gr.HTML` strips ` - -
- {#if error} -
{error}
- {:else if loading} -
Loading viewer...
- {/if} - -
- - -``` - -**Key improvements from audit feedback:** -- WebGL2 capability check before initialization -- WebGL context loss/restore handlers -- Proper error UI states -- Loading state management -- Reactive update when value changes - -### Phase 3: Implement Python Backend (2-3 hours) - -```python -# backend/gradio_niivue_viewer/__init__.py -from __future__ import annotations -from typing import Any -from gradio.components.base import Component -from gradio.data_classes import FileData, GradioModel - -class NiiVueViewerData(GradioModel): - background_url: str | None = None - overlay_url: str | None = None - -class NiiVueViewer(Component): - """WebGL NIfTI viewer using NiiVue.""" - - data_model = NiiVueViewerData - - def __init__( - self, - value: NiiVueViewerData | None = None, - *, - label: str | None = None, - height: int = 500, - **kwargs, - ): - self.height = height - super().__init__(value=value, label=label, **kwargs) - - def preprocess(self, payload: NiiVueViewerData | None) -> dict[str, Any] | None: - if payload is None: - return None - return { - "background_url": payload.background_url, - "overlay_url": payload.overlay_url, - } - - def postprocess(self, value: dict[str, Any] | None) -> NiiVueViewerData | None: - if value is None: - return None - return NiiVueViewerData( - background_url=value.get("background_url"), - overlay_url=value.get("overlay_url"), - ) -``` - -### Phase 4: Build and Test (2-3 hours) - -```bash -# Build the component -cd gradio-niivue-viewer -gradio cc build - -# Install locally -pip install -e . - -# Test in demo app -python demo/app.py -``` - -### Phase 5: Integrate into stroke-deepisles-demo (1-2 hours) - -Replace `gr.HTML` with the custom component: - -```python -# Before (broken) -from stroke_deepisles_demo.ui.viewer import create_niivue_html -viewer = gr.HTML(value="", elem_id="niivue-viewer") -# ... then set viewer.value = create_niivue_html(...) - -# After (working) -from gradio_niivue_viewer import NiiVueViewer -viewer = NiiVueViewer(label="Interactive 3D Viewer") -# ... then set viewer.value = {"background_url": dwi_url, "overlay_url": mask_url} -``` - -### Phase 6: HF Spaces Deployment (CRITICAL) - -**This phase is essential.** HF Spaces runs `pip install` only - it does NOT run `npm` or `gradio cc build`. - -#### 6a. Commit build artifacts to git - -```bash -cd packages/gradio-niivue-viewer - -# Build the component (generates frontend/dist/ or templates/) -gradio cc build - -# Force-add build artifacts (they may be gitignored by default) -git add -f gradio_niivue_viewer/templates/ -# Or wherever the build output lands - check with: -# find . -name "*.js" -path "*/dist/*" -o -name "*.css" -path "*/dist/*" - -git commit -m "chore: add compiled frontend assets for HF Spaces deployment" -``` - -**Why:** HF Spaces won't run npm/node build steps. The compiled JS/CSS must be in the repo. - -#### 6b. Update requirements.txt - -Add the local component to the main `requirements.txt`: - -```text -# requirements.txt -gradio>=5.0 -# ... other deps ... - -# Local custom component (editable install) --e ./packages/gradio-niivue-viewer -``` - -**Alternative:** If the component is at repo root: -```text --e . -``` - -#### 6c. Verify .gitignore doesn't exclude build artifacts - -Check that `packages/gradio-niivue-viewer/.gitignore` doesn't exclude: -- `gradio_niivue_viewer/templates/` -- `frontend/dist/` -- Any compiled `.js` or `.css` files needed at runtime - -If they're excluded, either: -1. Remove those lines from `.gitignore`, OR -2. Use `git add -f` to force-add them - -#### 6d. Test deployment flow - -```bash -# Simulate what HF Spaces does -pip install -r requirements.txt -python -m stroke_deepisles_demo.ui.app - -# Should work WITHOUT running gradio cc build -``` - ---- - -## Existing References - -### Working WebGL Custom Components - -1. **[gradio-litmodel3d](https://pypi.org/project/gradio-litmodel3d/)** - - WebGL Model3D viewer with HDR lighting - - Source: https://github.com/gradio-app/gradio/tree/main/demo/model3d_component - - Proof that WebGL works in Custom Components - -2. **[gradio-molecule3d](https://pypi.org/project/gradio-molecule3d/)** - - 3D molecule viewer - - Uses Three.js (WebGL) - -### Gradio Documentation - -- [Custom Components in 5 Minutes](https://www.gradio.app/guides/custom-components-in-five-minutes) -- [Gradio Components Documentation](https://www.gradio.app/docs/gradio/components) -- [Custom Component Gallery](https://www.gradio.app/custom-components/gallery) - -### NiiVue Resources - -- [NiiVue GitHub](https://github.com/niivue/niivue) -- [NiiVue npm](https://www.npmjs.com/package/@niivue/niivue) -- [NiiVue Examples](https://niivue.com/docs/) - ---- - -## Acceptance Criteria - -### Must Have (MVP) - -- [ ] Component loads NIfTI volumes from Gradio file URLs -- [ ] Component displays background image (DWI) -- [ ] Component displays overlay mask (segmentation) with colormap -- [ ] Component works on HuggingFace Spaces -- [ ] No "Loading..." hang - failures are graceful -- [ ] All existing tests pass - -### Nice to Have (Future) - -- [ ] Crosshair controls -- [ ] Slice orientation toggle (axial/coronal/sagittal) -- [ ] Opacity slider for overlay -- [ ] Pan/zoom/rotate controls -- [ ] Screenshot/export functionality -- [ ] Publish to PyPI for community use - ---- - -## Risk Assessment - -| Risk | Mitigation | -|------|------------| -| Svelte/TypeScript learning curve | Follow gradio-litmodel3d example closely | -| NiiVue WebGL2 browser support | Explicit WebGL2 check in Svelte + graceful error UI | -| Build system complexity | Use gradio cc tooling, don't customize | -| HF Spaces static file serving | Component bundles NiiVue, no external deps | -| **Build artifacts not in git** | Phase 6a: Force-add compiled assets with `git add -f` | -| **requirements.txt missing component** | Phase 6b: Add `-e ./packages/gradio-niivue-viewer` | - ---- - -## Alternatives Considered - -### Alternative 1: Keep Hacking gr.HTML -- **Effort:** Low -- **Success probability:** 0% (CONFIRMED FAILED) -- **Why rejected:** We tried 6 approaches over 2 days. ALL failed. This is not a viable path. - -### Alternative 2: Static HTML Space (No Gradio) -- **Effort:** High (rebuild entire UI) -- **Success probability:** 99% -- **Why rejected:** Lose Gradio's file upload, dropdowns, layout features. Too much work. - -### Alternative 3: Remove 3D Viewer (2D Only) -- **Effort:** Low -- **Success probability:** 100% -- **Why rejected:** Loses key feature. Static Report tab already works, but 3D is valuable. - ---- - -## Decision - -**Proceed with Gradio Custom Component approach.** - -This is the official Gradio-recommended solution. It's more work than hacking `gr.HTML`, but it's the architecturally correct approach with 90% success probability vs 30%. - ---- - -## Testing Matrix - -### Level 1: Local Build Verification - -```bash -cd packages/gradio-niivue-viewer - -# Build component -gradio cc build - -# Install locally -pip install -e . - -# Run demo app -python demo/app.py -# → Verify: App loads, no console errors, viewer renders -``` - -**Pass criteria:** -- [ ] `gradio cc build` completes without errors -- [ ] Demo app launches at localhost:7860 -- [ ] No JavaScript console errors -- [ ] Canvas renders (black background visible) - -### Level 2: Volume Loading Test - -```python -# demo/app.py -import gradio as gr -from gradio_niivue_viewer import NiiVueViewer - -def load_sample(): - # Use a known good NIfTI file - return { - "background_url": "/gradio_api/file=/path/to/sample.nii.gz", - "overlay_url": None - } - -with gr.Blocks() as demo: - viewer = NiiVueViewer() - btn = gr.Button("Load Sample") - btn.click(load_sample, outputs=viewer) - -demo.launch() -``` - -**Pass criteria:** -- [ ] NIfTI file loads without errors -- [ ] Multiplanar view displays correctly -- [ ] Overlay mask renders with red colormap (when provided) - -### Level 3: HF Spaces Dry Run - -Deploy to a **private/throwaway Space** before production: - -```bash -# Create test space -huggingface-cli repo create test-niivue-viewer --type space --private - -# Push and test -git push hf-test main -``` - -**Pass criteria:** -- [ ] Space shows "Running" (not stuck on "Loading...") -- [ ] Viewer initializes (no hydration deadlock) -- [ ] Volume loading works via Gradio file serving -- [ ] WebGL2 error shown gracefully if unsupported - -### Level 4: Integration Test - -Replace `gr.HTML` in stroke-deepisles-demo: - -```python -# src/stroke_deepisles_demo/ui/components.py -from gradio_niivue_viewer import NiiVueViewer - -def create_results_display(): - # ... - niivue_viewer = NiiVueViewer(label="Interactive 3D Viewer") - # ... -``` - -**Pass criteria:** -- [ ] Existing 136 tests still pass -- [ ] Segmentation pipeline works end-to-end -- [ ] Viewer displays DWI + mask overlay -- [ ] No "Loading..." hang on HF Spaces - ---- - -## Next Steps - -1. [x] Senior review of this spec (AUDIT_REPORT_2025_12_10.md) -2. [x] Red team review - all gaps addressed (build artifacts, npm install, null handling) -3. [ ] Create `packages/gradio-niivue-viewer/` subdirectory -4. [ ] Scaffold component with `gradio cc create` -5. [ ] Install NiiVue: `cd frontend && npm install @niivue/niivue@0.65.0` -6. [ ] Implement Svelte frontend (with WebGL2 checks + null value handling) -7. [ ] Implement Python backend -8. [ ] Level 1 test: Local build verification -9. [ ] Level 2 test: Volume loading -10. [ ] Level 3 test: HF Spaces dry run -11. [ ] Level 4 test: Integration -12. [ ] **CRITICAL**: Commit build artifacts to git -13. [ ] **CRITICAL**: Update requirements.txt with `-e ./packages/gradio-niivue-viewer` -14. [ ] (Optional) Publish to PyPI - ---- - -## Appendix: Why WebGL + `gr.HTML` Doesn't Work - -From the ROOT_CAUSE_ANALYSIS.md and GRADIO_WEBGL_ANALYSIS.md research: - -1. **Gradio CAN do WebGL** - proven by `gradio-litmodel3d` custom component -2. **But NOT via `gr.HTML`** - the `js_on_load` + `import()` pattern blocks Svelte hydration -3. **Our A/B test proved it** - disabling `js_on_load` makes the app load perfectly -4. **Gradio closed NIfTI support** (Issue #4511) - "Not planned for core" -5. **Gradio closed WebGL canvas** (Issue #7649) - "Not planned for core" -6. **gr.HTML strips script tags** - Security feature, can't bypass -7. **HF Spaces CSP blocks external CDNs** - Must vendor or bundle dependencies -8. **Gradio maintainer recommendation**: Custom Components - -The pattern is clear: **The `gr.HTML` + `js_on_load` + async `import()` pattern is fundamentally broken.** The Custom Component is the officially supported path for WebGL content. diff --git a/docs/specs/29-codebase-status-audit.md b/docs/specs/29-codebase-status-audit.md deleted file mode 100644 index ed4fc4886c977ab39ae36cb208f8ad5f57bdb3bf..0000000000000000000000000000000000000000 --- a/docs/specs/29-codebase-status-audit.md +++ /dev/null @@ -1,276 +0,0 @@ -# Spec #29: Codebase Status Audit (Issue #24 NiiVue/WebGL) - -**Date:** 2025-12-10 -**Status:** ALL `gr.HTML` HACKS CONFIRMED FAILED (Dec 10, 2025) -**Purpose:** Top-down analysis of current frontend/NiiVue implementation state after multiple hotfix attempts - ---- - -## Executive Summary: The `gr.HTML` + `js_on_load` + `import()` Pattern is Broken - -After 6 iterations of attempted hotfixes for Issue #24 (HF Spaces "Loading..." forever), **every `gr.HTML`-based approach has failed**: - -| Attempt | Result | -|---------|--------| -| CDN import | FAILED - CSP blocked | -| Vendored + js_on_load import() | FAILED - Blocks Svelte hydration | -| head_paths | FAILED - Same hydration issue | -| head= with import() | **FAILED** - Confirmed Dec 10 | - -**Root Cause (PROVEN):** Async `import()` inside `js_on_load` blocks Gradio's Svelte hydration. Our A/B test confirmed: disabling `js_on_load` makes the app load. - -**Clarification:** Gradio CAN do WebGL via Custom Components (`gradio-litmodel3d` proves this). The issue is the `gr.HTML` approach, not Gradio itself. - -**The correct solution is Gradio Custom Component (spec #28).** - ---- - -## Current Frontend Architecture - -### File Inventory - -| File | Purpose | Lines | Status | -|------|---------|-------|--------| -| `ui/viewer.py` | NiiVue HTML/JS generation | 643 | **BLOATED** - contains 5 approaches | -| `ui/app.py` | Main Gradio app | 313 | Clean | -| `ui/components.py` | UI components | 94 | Clean | -| `app.py` (root) | Local dev entry | 61 | Clean | -| `ui/assets/niivue.js` | Vendored NiiVue v0.65.0 | 2.9MB | **NECESSARY** | - -### What's in `viewer.py` Right Now - -| Component | Lines | Status | Notes | -|-----------|-------|--------|-------| -| `NIIVUE_VERSION` | 30 | OK | Version tracking | -| `_ASSET_DIR`, `_NIIVUE_JS_PATH` | 31-32 | OK | Path constants | -| `NIIVUE_JS_URL` | 36 | **UNUSED** | Computed but not actually used | -| Module-level logging | 39-42 | **SLOP** | 4 log statements at import time | -| `get_niivue_head_html()` | 45-77 | **PROBLEMATIC** | Still uses `await import()` | -| `get_niivue_loader_path()` | 80-109 | **DEPRECATED** | Marked deprecated but still exists | -| `nifti_to_gradio_url()` | 112-142 | OK | Issue #19 fix, working | -| `get_slice_at_max_lesion()` | 145-187 | OK | Matplotlib helper | -| `render_3panel_view()` | 190-281 | OK | Matplotlib 3-panel | -| `render_slice_comparison()` | 284-380 | OK | Matplotlib comparison | -| `create_niivue_html()` | 383-434 | OK | HTML generation | -| `NIIVUE_ON_LOAD_JS` | 449-538 | **MOSTLY OK** | No import(), uses window.Niivue | -| `NIIVUE_UPDATE_JS` | 546-642 | **MOSTLY OK** | No import(), uses window.Niivue | - ---- - -## The Core Problem: `get_niivue_head_html()` Still Uses `import()` - -The current "fix" in `get_niivue_head_html()` does this: - -```javascript -// viewer.py:63-76 - -``` - -**This is the EXACT same `await import()` pattern that breaks on HF Spaces.** - -The only difference from our previous attempts: -- Before: `await import()` in `js_on_load` -- Now: `await import()` in `head=` script - -**Why this might not matter:** The A/B test proved that `js_on_load` with async code breaks Gradio. Moving the `import()` to `head=` might help, but it's still executing async code that could fail silently and leave `window.Niivue` undefined. - ---- - -## What's Necessary vs What's Slop - -### NECESSARY (Keep) - -| Item | Why | -|------|-----| -| `ui/assets/niivue.js` | HF Spaces CSP blocks CDN imports | -| `gr.set_static_paths()` | Required for Gradio 6.x file serving | -| `nifti_to_gradio_url()` | Issue #19 fix, working | -| `create_niivue_html()` | Generates viewer HTML | -| `NIIVUE_ON_LOAD_JS` | Initializes viewer (doesn't import) | -| `NIIVUE_UPDATE_JS` | Re-initializes after updates | -| Matplotlib functions | Working 2D fallback | -| `allowed_paths` in launch() | Runtime file access | - -### SLOP (Should Remove/Refactor) - -| Item | Why It's Slop | -|------|---------------| -| `NIIVUE_JS_URL` module-level computation | Computed but unused in production | -| Module-level logging (lines 39-42) | Noisy startup logs, not useful | -| `get_niivue_loader_path()` | Deprecated, generates file we don't need | -| `get_niivue_head_html()` with import() | Still uses broken pattern | -| Multiple diagnostic docs | Overlapping, contradictory, stale | - -### UNCERTAIN (Depends on head= fix working) - -| Item | Status | -|------|--------| -| `head=get_niivue_head_html()` in launch() | **30% chance this works** | - ---- - -## Documentation Status - -### docs/specs/ Files - -| File | Status | Issue | -|------|--------|-------| -| `00-context.md` | **ACCURATE** | None | -| `28-gradio-custom-component-niivue.md` | **ACCURATE** | Just written | -| `AUDIT_JS_LOADING_ISSUES.md` | **OUTDATED** | Says `set_static_paths` is blocker, but we've moved past that | -| `DIAGNOSTIC_HF_LOADING.md` | **OUTDATED** | Lists hypotheses we've since disproven | -| `ROOT_CAUSE_ANALYSIS.md` | **PARTIALLY OUTDATED** | Says "IN PROGRESS", discusses head= as solution | -| `GRADIO_WEBGL_ANALYSIS.md` | **ACCURATE** | Core analysis, identifies real problem | - -### docs/TECHNICAL_DEBT.md - -| Status | Issue | -|--------|-------| -| **OUTDATED** | Claims "Ironclad/Production-Ready" but doesn't mention P0 NiiVue/WebGL blocker | - ---- - -## Recommended Cleanup Actions - -### Immediate (If head= fix fails) - -1. **Delete deprecated code:** - - Remove `get_niivue_loader_path()` - - Remove module-level logging - - Clean up `NIIVUE_JS_URL` if unused - -2. **Archive old diagnostic docs:** - - Move `AUDIT_JS_LOADING_ISSUES.md` to `archive/` - - Move `DIAGNOSTIC_HF_LOADING.md` to `archive/` - - Update `ROOT_CAUSE_ANALYSIS.md` status - -3. **Update TECHNICAL_DEBT.md:** - - Add P0 section for NiiVue/WebGL blocker - - Link to spec #28 (Custom Component) - -### Long-term (After decision on path forward) - -1. **If Custom Component route:** - - Remove all `head=` NiiVue loading code - - Remove `get_niivue_head_html()` - - Simplify `viewer.py` to just Matplotlib functions - - NiiVue loading becomes the component's responsibility - -2. **If 2D fallback route:** - - Remove entire NiiVue integration - - Remove `ui/assets/niivue.js` (2.9MB) - - Remove `NIIVUE_ON_LOAD_JS`, `NIIVUE_UPDATE_JS` - - Keep only Matplotlib rendering - ---- - -## Honest Assessment - -### What We've Tried (6+ iterations) - -1. **CDN import** → Blocked by CSP -2. **Vendored + dynamic import in js_on_load** → Blocks Svelte hydration -3. **head_paths with loader HTML** → Complex, didn't work -4. **head= with inline import()** → Current state, **probably won't work** -5. **Various set_static_paths/allowed_paths combos** → File serving works, JS loading doesn't - -### The Pattern - -Every attempt has been a variation of: -> "Load NiiVue via some JavaScript mechanism within Gradio" - -Every attempt has failed because: -> **Gradio was not designed for custom WebGL content** - -### The Correct Solution - -**Stop fighting Gradio's architecture. Use a Gradio Custom Component.** - -This is: -- What Gradio maintainers recommend (Issues #4511, #7649) -- How existing WebGL components work (gradio-litmodel3d) -- 90% success probability vs 30% for more hacks - -See spec #28 for implementation details. - ---- - -## Current Entry Point Flow - -``` -HF Spaces Docker - ↓ -CMD ["python", "-m", "stroke_deepisles_demo.ui.app"] - ↓ -ui/app.py __main__ block - ↓ -gr.set_static_paths([_ASSETS_DIR]) # Enable file serving - ↓ -get_demo() # Creates Blocks with js_on_load components - ↓ -demo.launch( - head=get_niivue_head_html(), # <-- Injects ')` + +**Result:** FAILED + +**Why:** This is Gradio 5 syntax. In Gradio 6, `head=`, `js=`, `css=` moved from `gr.Blocks()` to `launch()`. + +**Source:** [Gradio 6 Migration Guide](https://www.gradio.app/main/guides/gradio-6-migration-guide) + +--- + +### 5. head= Parameter on launch() with import() + +**What we did:** `demo.launch(head='')` + +**Result:** FAILED + +**Why:** Same problem as #2 - async import() still blocks hydration even when in ``. + +**Additional Issue:** [GitHub Issue #10250](https://github.com/gradio-app/gradio/issues/10250) documents that JavaScript in `head` param has non-deterministic execution - "would sometimes execute only after extended waiting periods (5+ minutes), or occasionally not at all." + +--- + +### 6. head_paths= Parameter + +**What we did:** Used `head_paths=['path/to/niivue-loader.html']` + +**Result:** FAILED + +**Why:** Multiple issues: +- Files weren't served correctly without `gr.set_static_paths()` +- [GitHub Issue #11649](https://github.com/gradio-app/gradio/issues/11649) - head= with file paths causes 404 +- Even when files served correctly, the async import() pattern still blocked hydration + +--- + +### 7. Vendored NiiVue (Local File) + +**What we did:** Downloaded niivue.js (2.9MB) locally to bypass CDN/CSP issues + +**Result:** PARTIALLY WORKED (file served) but FAILED (still blocks hydration) + +**Why:** The file serving worked via `allowed_paths` and `gr.set_static_paths()`, but the async import() pattern still blocked Svelte hydration. + +**Secondary Issue:** Base64 payload risk - encoding DWI (~30MB) + ADC (~18MB) as base64 would create ~65MB payloads, risking browser memory issues and Gradio payload limits. + +--- + +### 8. Head Script + MutationObserver Pattern + +**What we did:** Load NiiVue via `launch(head=...)` globally, then use MutationObserver to watch for DOM changes and trigger initialization + +**Result:** FAILED + +**Why:** The head script still uses `await import()` which blocks hydration. Even if NiiVue loads globally as `window.Niivue`, the async pattern during page load still freezes the UI. + +**What it should have done:** MutationObserver watches for `data-*` attribute changes on gr.HTML elements, then calls `initNiiVue()`. But the initial import never completes due to hydration blocking. + +--- + +### 9. Custom Svelte Component (packages/niivueviewer/) + +**What we did:** Built a full Gradio custom component: +- Svelte 5 frontend with NiiVue bundled via npm (`@niivue/niivue@0.65.0`) +- Python backend component class +- StatusTracker for loading states +- Templates compiled via `gradio cc build` + +**Result:** FAILED - Froze entire UI + +**Issues encountered:** + +| Issue | Description | Fix Applied | Still Broken | +|-------|-------------|-------------|--------------| +| Missing `gradio` prop | StatusTracker requires `gradio.i18n` for translations | PR #31 added it | Yes | +| Missing packages/ in Docker | Dockerfile didn't copy `packages/` directory | PR #30 added COPY | Yes | +| style.css 404 | [Issue #7026](https://github.com/gradio-app/gradio/issues/7026) - causes loading hang | N/A | Yes | +| CJS/ESM conflicts | [Issue #6087](https://github.com/gradio-app/gradio/issues/6087) - dev server stuck | N/A | Yes | +| Templates undefined | [Issue #9879](https://github.com/gradio-app/gradio/issues/9879) | N/A | Yes | + +**Time spent:** ~1 day on this approach alone + +**Root cause unresolved:** Even after adding `gradio` prop and shipping templates, UI still completely frozen. The exact failure mode remains unknown. + +**Gradio team's stance on custom components:** +- [Issue #12074](https://github.com/gradio-app/gradio/issues/12074) - Proposed `gr.Custom` class because custom components are "too complex" +- Custom components have multiple fragile failure modes that make them riskier than simpler approaches + +--- + +### 10. gradio-iframe Package + +**What we did:** Attempted to use `gradio-iframe` package to isolate NiiVue in an iframe + +**Result:** FAILED - Incompatible with Gradio 6 + +**Why:** `gradio-iframe` version 0.0.10 (Jan 2024) is the latest. Installing it forces Gradio downgrade from 6.x to 4.x. Package is effectively abandoned. + +**Source:** [PyPI gradio-iframe](https://pypi.org/project/gradio-iframe/) + +--- + +### 11. Inline iframe via gr.HTML (NOT TESTED) + +**What we planned:** Use `gr.HTML('')` with standalone viewer HTML + +**Result:** PLANNED BUT NOT EXECUTED + +**Why we stopped:** After 2+ days of failures, we stopped here because: +- No definitive evidence it works on HF Spaces +- CSP may still block JavaScript in iframes +- 50/50 chance of working - yet another gamble + +**This remains the only untested approach** that might theoretically work. + +--- + +## Root Causes + +### 1. Gradio's innerHTML Security Model + +Gradio uses innerHTML to update component values. Browsers intentionally do not execute ` - """ -``` - -## interfaces and types - -### `ui/app.py` - -```python -"""Main Gradio application for stroke-deepisles-demo.""" - -from __future__ import annotations - -import gradio as gr - -from stroke_deepisles_demo.pipeline import run_pipeline_on_case -from stroke_deepisles_demo.ui.components import create_case_selector, create_results_display -from stroke_deepisles_demo.ui.viewer import render_comparison_view - - -def create_app() -> gr.Blocks: - """ - Create the Gradio application. - - Returns: - Configured gr.Blocks application - """ - with gr.Blocks( - title="Stroke Lesion Segmentation Demo", - theme=gr.themes.Soft(), - ) as demo: - # Header - gr.Markdown(""" - # Stroke Lesion Segmentation Demo - - This demo runs [DeepISLES](https://github.com/ezequieldlrosa/DeepIsles) - stroke segmentation on cases from - [ISLES24-MR-Lite](https://huggingface.co/datasets/YongchengYAO/ISLES24-MR-Lite). - - > **Disclaimer**: This is for research/demonstration only. Not for clinical use. - """) - - with gr.Row(): - # Left column: Controls - with gr.Column(scale=1): - case_selector = create_case_selector() - run_btn = gr.Button("Run Segmentation", variant="primary") - status = gr.Textbox(label="Status", interactive=False) - - # Right column: Results - with gr.Column(scale=2): - results_display = create_results_display() - - # Event handlers - run_btn.click( - fn=run_segmentation, - inputs=[case_selector], - outputs=[results_display, status], - ) - - return demo - - -def run_segmentation(case_id: str) -> tuple[dict, str]: - """ - Run segmentation and return results for display. - - Args: - case_id: Selected case identifier - - Returns: - Tuple of (results_dict, status_message) - """ - ... - - -# Module-level app instance for Gradio CLI -demo = create_app() - -if __name__ == "__main__": - demo.launch() -``` - -### `ui/viewer.py` - -```python -"""Neuroimaging visualization for Gradio.""" - -from __future__ import annotations - -from pathlib import Path -from typing import TYPE_CHECKING - -import numpy as np - -if TYPE_CHECKING: - from matplotlib.figure import Figure - from numpy.typing import NDArray - - -def render_slice_comparison( - dwi_path: Path, - prediction_path: Path, - ground_truth_path: Path | None = None, - *, - slice_idx: int | None = None, - orientation: str = "axial", -) -> Figure: - """ - Render side-by-side comparison of DWI, prediction, and ground truth. - - Args: - dwi_path: Path to DWI NIfTI - prediction_path: Path to predicted mask NIfTI - ground_truth_path: Optional path to ground truth mask - slice_idx: Slice index (default: middle slice) - orientation: One of "axial", "coronal", "sagittal" - - Returns: - Matplotlib figure with comparison view - """ - ... - - -def render_3panel_view( - nifti_path: Path, - mask_path: Path | None = None, - *, - mask_alpha: float = 0.5, - mask_color: str = "red", -) -> Figure: - """ - Render axial/coronal/sagittal slices with optional mask overlay. - - Args: - nifti_path: Path to base NIfTI volume - mask_path: Optional path to mask for overlay - mask_alpha: Transparency of mask overlay - mask_color: Color for mask overlay - - Returns: - Matplotlib figure with 3-panel view - """ - ... - - -def create_niivue_html( - volume_url: str, - mask_url: str | None = None, - *, - height: int = 400, -) -> str: - """ - Create HTML/JS for NiiVue viewer. - - Args: - volume_url: URL to volume NIfTI file - mask_url: Optional URL to mask NIfTI file - height: Viewer height in pixels - - Returns: - HTML string with embedded NiiVue viewer - """ - template = f""" -
- - """ - return template - - -def get_slice_at_max_lesion( - mask_path: Path, - orientation: str = "axial", -) -> int: - """ - Find slice index with maximum lesion area. - - Useful for displaying the most informative slice. - - Args: - mask_path: Path to lesion mask NIfTI - orientation: Slice orientation - - Returns: - Slice index with maximum lesion area - """ - ... -``` - -### `ui/components.py` - -```python -"""Reusable UI components.""" - -from __future__ import annotations - -import gradio as gr - -from stroke_deepisles_demo.data import list_case_ids - - -def create_case_selector() -> gr.Dropdown: - """ - Create a dropdown for selecting cases. - - Returns: - Configured gr.Dropdown component - """ - try: - case_ids = list_case_ids() - except Exception: - case_ids = ["Error loading cases"] - - return gr.Dropdown( - choices=case_ids, - value=case_ids[0] if case_ids else None, - label="Select Case", - info="Choose a case from ISLES24-MR-Lite", - ) - - -def create_results_display() -> dict[str, gr.components.Component]: - """ - Create results display components. - - Returns: - Dictionary of component name -> gr.Component - """ - with gr.Group(): - viewer = gr.Image(label="Segmentation Result", type="filepath") - metrics = gr.JSON(label="Metrics") - download = gr.File(label="Download Prediction") - - return { - "viewer": viewer, - "metrics": metrics, - "download": download, - } - - -def create_settings_accordion() -> dict[str, gr.components.Component]: - """ - Create expandable settings section. - - Returns: - Dictionary of setting name -> gr.Component - """ - with gr.Accordion("Advanced Settings", open=False): - fast_mode = gr.Checkbox( - value=True, - label="Fast Mode (SEALS)", - info="Run SEALS only (ISLES'22 winner, requires DWI+ADC). Disable for full ensemble (requires FLAIR).", - ) - show_ground_truth = gr.Checkbox( - value=True, - label="Show Ground Truth", - info="Display ground truth mask if available", - ) - - return { - "fast_mode": fast_mode, - "show_ground_truth": show_ground_truth, - } -``` - -### Root `app.py` for HF Spaces - -```python -"""Entry point for Hugging Face Spaces deployment.""" - -from stroke_deepisles_demo.ui.app import demo - -if __name__ == "__main__": - demo.launch() -``` - -## hugging face spaces configuration - -### `README.md` header for Spaces - -```yaml ---- -title: Stroke DeepISLES Demo -emoji: 🧠 -colorFrom: blue -colorTo: purple -sdk: gradio -sdk_version: 5.0.0 -app_file: app.py -pinned: false -license: mit ---- -``` - -### `requirements.txt` for Spaces - -``` -# Note: HF Spaces uses requirements.txt, not pyproject.toml -git+https://github.com/CloseChoice/datasets.git@feat/bids-loader-streaming-upload-fix -huggingface-hub>=0.25.0 -nibabel>=5.2.0 -numpy>=1.26.0 -pydantic>=2.5.0 -pydantic-settings>=2.1.0 -gradio>=5.0.0 -matplotlib>=3.8.0 -``` - -## tdd plan - -### test file structure - -``` -tests/ -├── ui/ -│ ├── __init__.py -│ ├── test_viewer.py # Tests for visualization -│ ├── test_components.py # Tests for UI components -│ └── test_app.py # Smoke tests for app -``` - -### tests to write first (TDD order) - -#### 1. `tests/ui/test_viewer.py` - Pure visualization functions - -```python -"""Tests for viewer module.""" - -from __future__ import annotations - -from pathlib import Path - -import matplotlib -import matplotlib.pyplot as plt -import numpy as np -import pytest - -matplotlib.use("Agg") # Non-interactive backend for tests - -from stroke_deepisles_demo.ui.viewer import ( - create_niivue_html, - get_slice_at_max_lesion, - render_3panel_view, - render_slice_comparison, -) - - -class TestRender3PanelView: - """Tests for render_3panel_view.""" - - def test_returns_matplotlib_figure(self, synthetic_nifti_3d: Path) -> None: - """Returns a matplotlib Figure object.""" - fig = render_3panel_view(synthetic_nifti_3d) - - assert isinstance(fig, plt.Figure) - plt.close(fig) - - def test_has_three_axes(self, synthetic_nifti_3d: Path) -> None: - """Figure has 3 subplots (axial, coronal, sagittal).""" - fig = render_3panel_view(synthetic_nifti_3d) - - assert len(fig.axes) == 3 - plt.close(fig) - - def test_overlay_mask_when_provided( - self, synthetic_nifti_3d: Path, temp_dir: Path - ) -> None: - """Overlays mask when mask_path provided.""" - # Create a simple mask - import nibabel as nib - - mask_data = np.zeros((10, 10, 10), dtype=np.uint8) - mask_data[4:6, 4:6, 4:6] = 1 - mask_img = nib.Nifti1Image(mask_data, np.eye(4)) - mask_path = temp_dir / "mask.nii.gz" - nib.save(mask_img, mask_path) - - fig = render_3panel_view(synthetic_nifti_3d, mask_path=mask_path) - - # Should not raise - assert fig is not None - plt.close(fig) - - -class TestRenderSliceComparison: - """Tests for render_slice_comparison.""" - - def test_comparison_without_ground_truth( - self, synthetic_nifti_3d: Path - ) -> None: - """Works when ground truth is None.""" - fig = render_slice_comparison( - synthetic_nifti_3d, - synthetic_nifti_3d, # Use same as prediction for test - ground_truth_path=None, - ) - - assert isinstance(fig, plt.Figure) - plt.close(fig) - - def test_comparison_with_ground_truth( - self, synthetic_nifti_3d: Path - ) -> None: - """Works when ground truth is provided.""" - fig = render_slice_comparison( - synthetic_nifti_3d, - synthetic_nifti_3d, - ground_truth_path=synthetic_nifti_3d, - ) - - assert isinstance(fig, plt.Figure) - plt.close(fig) - - -class TestGetSliceAtMaxLesion: - """Tests for get_slice_at_max_lesion.""" - - def test_finds_slice_with_lesion(self, temp_dir: Path) -> None: - """Returns slice index where lesion is largest.""" - import nibabel as nib - - # Create mask with lesion at slice 7 - mask_data = np.zeros((10, 10, 10), dtype=np.uint8) - mask_data[:, :, 7] = 1 # Full slice 7 is lesion - - mask_img = nib.Nifti1Image(mask_data, np.eye(4)) - mask_path = temp_dir / "mask.nii.gz" - nib.save(mask_img, mask_path) - - slice_idx = get_slice_at_max_lesion(mask_path, orientation="axial") - - assert slice_idx == 7 - - def test_returns_middle_for_empty_mask(self, temp_dir: Path) -> None: - """Returns middle slice when mask is empty.""" - import nibabel as nib - - mask_data = np.zeros((10, 10, 20), dtype=np.uint8) - mask_img = nib.Nifti1Image(mask_data, np.eye(4)) - mask_path = temp_dir / "mask.nii.gz" - nib.save(mask_img, mask_path) - - slice_idx = get_slice_at_max_lesion(mask_path, orientation="axial") - - assert slice_idx == 10 # Middle of 20 - - -class TestCreateNiivueHtml: - """Tests for create_niivue_html.""" - - def test_includes_volume_url(self) -> None: - """Generated HTML includes the volume URL.""" - html = create_niivue_html("http://example.com/brain.nii.gz") - - assert "http://example.com/brain.nii.gz" in html - - def test_includes_mask_when_provided(self) -> None: - """Generated HTML includes mask URL when provided.""" - html = create_niivue_html( - "http://example.com/brain.nii.gz", - mask_url="http://example.com/mask.nii.gz", - ) - - assert "http://example.com/mask.nii.gz" in html - - def test_sets_height(self) -> None: - """Generated HTML respects height parameter.""" - html = create_niivue_html( - "http://example.com/brain.nii.gz", - height=600, - ) - - assert "height:600px" in html -``` - -#### 2. `tests/ui/test_app.py` - Smoke tests - -```python -"""Smoke tests for Gradio app.""" - -from __future__ import annotations - - -def test_app_module_imports() -> None: - """App module imports without side effects.""" - # This should not launch the app or make network calls - from stroke_deepisles_demo.ui import app - - assert hasattr(app, "create_app") - assert hasattr(app, "demo") - - -def test_create_app_returns_blocks() -> None: - """create_app returns a gr.Blocks instance.""" - import gradio as gr - - from stroke_deepisles_demo.ui.app import create_app - - app = create_app() - - assert isinstance(app, gr.Blocks) - - -def test_viewer_module_imports() -> None: - """Viewer module imports without errors.""" - from stroke_deepisles_demo.ui import viewer - - assert hasattr(viewer, "render_3panel_view") - assert hasattr(viewer, "create_niivue_html") - - -def test_components_module_imports() -> None: - """Components module imports without errors.""" - from stroke_deepisles_demo.ui import components - - assert hasattr(components, "create_case_selector") - assert hasattr(components, "create_results_display") -``` - -### what to mock - -- `list_case_ids()` in components - Avoid network during import -- Any data loading in app initialization - -### what to test for real - -- Matplotlib figure generation -- NiiVue HTML string generation -- Slice finding algorithms -- Module imports (no network side effects) - -## "done" criteria - -Phase 4 is complete when: - -1. All unit tests pass: `uv run pytest tests/ui/ -v` -2. App launches locally: `uv run python -m stroke_deepisles_demo.ui.app` -3. Can select a case, click "Run", see visualization -4. Visualization shows DWI with predicted mask overlay -5. Metrics (Dice score) displayed -6. Type checking passes: `uv run mypy src/stroke_deepisles_demo/ui/` -7. Ready for HF Spaces deployment (README header, requirements.txt) - -## implementation notes - -- **NiiVue is primary** - Proven working in Tobias's Space, not "fragile" -- **Base64 data URLs** - Avoids file serving complexity, works in all environments -- **Lazy initialization** - Do NOT call `list_case_ids()` at module import time (causes network calls) -- **Test on HF Spaces early** - Verify WebGL works in their environment -- **Keep UI simple** - This is a demo, not a full application -- **Cache case list** - Avoid repeated HF Hub calls - -### avoiding import-time side effects - -The reviewer correctly noted that `demo = create_app()` at module level triggers network calls. Fix: - -```python -# BAD - triggers network call on import -demo = create_app() - -# GOOD - lazy initialization -_demo: gr.Blocks | None = None - -def get_demo() -> gr.Blocks: - global _demo - if _demo is None: - _demo = create_app() - return _demo - -# For Gradio CLI compatibility -demo = None # Set lazily - -if __name__ == "__main__": - get_demo().launch() -``` - -Or use a factory pattern in the root `app.py`: - -```python -# app.py (HF Spaces entry point) -from stroke_deepisles_demo.ui.app import create_app - -demo = create_app() # Only called when this file is executed - -if __name__ == "__main__": - demo.launch() -``` - -## dependencies to add - -```toml -# Add to pyproject.toml dependencies -"matplotlib>=3.8.0", # For static slice rendering in viewer.py -``` - -## reference implementation - -Clone Tobias's working Space for reference: -``` -_reference_repos/bids-neuroimaging-space/ -``` - -Key file: `main.py` - Complete NiiVue + FastAPI implementation. diff --git a/docs/specs/archive/06-phase-5-polish.md b/docs/specs/archive/06-phase-5-polish.md deleted file mode 100644 index 396fb016399f8747f8dc4d2b136a98b5c7d509ba..0000000000000000000000000000000000000000 --- a/docs/specs/archive/06-phase-5-polish.md +++ /dev/null @@ -1,667 +0,0 @@ -# phase 5: polish, observability, and docs - -## purpose - -Add production-quality polish: structured logging, environment-driven configuration, comprehensive documentation, and CI readiness. At the end of this phase, the codebase is maintainable, debuggable, and ready for others to contribute. - -## deliverables - -- [ ] Structured logging throughout all modules -- [ ] Environment-driven configuration via pydantic-settings -- [ ] Developer documentation (CONTRIBUTING.md, architecture) -- [ ] API documentation (docstrings, optional Sphinx/mkdocs) -- [ ] CI configuration (GitHub Actions) -- [ ] Final cleanup and code review checklist - -## logging strategy - -### centralized logging setup - -```python -# src/stroke_deepisles_demo/core/logging.py - -"""Centralized logging configuration.""" - -from __future__ import annotations - -import logging -import sys -from typing import Literal - -LogLevel = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] - - -def setup_logging( - level: LogLevel = "INFO", - *, - format_style: Literal["simple", "detailed", "json"] = "simple", -) -> None: - """ - Configure logging for the application. - - Args: - level: Minimum log level - format_style: Output format style - - Example: - >>> setup_logging("DEBUG", format_style="detailed") - """ - formats = { - "simple": "%(levelname)s: %(message)s", - "detailed": "%(asctime)s | %(name)s | %(levelname)s | %(message)s", - "json": '{"time": "%(asctime)s", "name": "%(name)s", "level": "%(levelname)s", "message": "%(message)s"}', - } - - logging.basicConfig( - level=getattr(logging, level), - format=formats[format_style], - stream=sys.stderr, - force=True, - ) - - # Reduce noise from libraries - logging.getLogger("urllib3").setLevel(logging.WARNING) - logging.getLogger("httpx").setLevel(logging.WARNING) - logging.getLogger("datasets").setLevel(logging.WARNING) - - -def get_logger(name: str) -> logging.Logger: - """ - Get a logger for a module. - - Args: - name: Logger name (typically __name__) - - Returns: - Configured logger instance - """ - return logging.getLogger(f"stroke_demo.{name}") -``` - -### logging usage pattern - -```python -# In each module -from stroke_deepisles_demo.core.logging import get_logger - -logger = get_logger(__name__) - - -def run_deepisles_on_folder(input_dir: Path, *, fast: bool = True) -> DeepISLESResult: - logger.info("Starting DeepISLES inference", extra={"input_dir": str(input_dir), "fast": fast}) - - try: - result = _run_docker(...) - logger.info("Inference complete", extra={"elapsed": result.elapsed_seconds}) - return result - except Exception as e: - logger.error("Inference failed", extra={"error": str(e)}, exc_info=True) - raise -``` - -## enhanced configuration - -### `src/stroke_deepisles_demo/core/config.py` - -```python -"""Application configuration using pydantic-settings.""" - -from __future__ import annotations - -from pathlib import Path -from typing import Literal - -from pydantic import Field, field_validator -from pydantic_settings import BaseSettings, SettingsConfigDict - - -class Settings(BaseSettings): - """ - Application settings loaded from environment variables. - - All settings can be overridden via environment variables with - the STROKE_DEMO_ prefix. - - Example: - export STROKE_DEMO_LOG_LEVEL=DEBUG - export STROKE_DEMO_HF_DATASET_ID=my/dataset - """ - - model_config = SettingsConfigDict( - env_prefix="STROKE_DEMO_", - env_file=".env", - env_file_encoding="utf-8", - extra="ignore", - ) - - # Logging - log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO" - log_format: Literal["simple", "detailed", "json"] = "simple" - - # HuggingFace - hf_dataset_id: str = "YongchengYAO/ISLES24-MR-Lite" - hf_cache_dir: Path | None = None - hf_token: str | None = Field(default=None, repr=False) # Hidden from logs - - # DeepISLES - deepisles_docker_image: str = "isleschallenge/deepisles" - deepisles_fast_mode: bool = True # SEALS-only (ISLES'22 winner, no FLAIR needed) - deepisles_timeout_seconds: int = 1800 # 30 minutes - deepisles_use_gpu: bool = True - - # Paths - temp_dir: Path | None = None - results_dir: Path = Path("./results") - - # UI - gradio_server_name: str = "0.0.0.0" - gradio_server_port: int = 7860 - gradio_share: bool = False - - @field_validator("results_dir", mode="before") - @classmethod - def ensure_results_dir_exists(cls, v: Path | str) -> Path: - """Create results directory if it doesn't exist.""" - path = Path(v) - path.mkdir(parents=True, exist_ok=True) - return path - - -# Global settings instance -settings = Settings() - - -def get_settings() -> Settings: - """Get the current settings instance.""" - return settings - - -def reload_settings() -> Settings: - """Reload settings from environment (useful for testing).""" - global settings - settings = Settings() - return settings -``` - -## documentation structure - -``` -docs/ -├── specs/ # Design specs (these documents) -│ ├── 00-context.md -│ ├── 01-phase-0-repo-bootstrap.md -│ ├── ... -│ └── 06-phase-5-polish.md -│ -├── guides/ # User guides -│ ├── quickstart.md # Getting started -│ ├── configuration.md # Environment variables -│ └── deployment.md # HF Spaces deployment -│ -└── reference/ # API reference (auto-generated) - └── api.md - -# Root level -README.md # Project overview -CONTRIBUTING.md # Contribution guidelines -CHANGELOG.md # Version history -``` - -### `CONTRIBUTING.md` - -```markdown -# Contributing to stroke-deepisles-demo - -Thank you for your interest in contributing! - -## Development Setup - -1. **Clone the repository** - ```bash - git clone https://github.com/The-Obstacle-Is-The-Way/stroke-deepisles-demo.git - cd stroke-deepisles-demo - ``` - -2. **Install uv** (if not already installed) - ```bash - curl -LsSf https://astral.sh/uv/install.sh | sh - ``` - -3. **Install dependencies** - ```bash - uv sync - ``` - -4. **Install pre-commit hooks** - ```bash - uv run pre-commit install - ``` - -## Running Tests - -```bash -# All tests (excluding integration) -uv run pytest - -# With coverage -uv run pytest --cov - -# Integration tests (requires Docker) -uv run pytest -m integration - -# Slow tests (requires Docker + DeepISLES image) -uv run pytest -m "integration and slow" -``` - -## Code Quality - -```bash -# Lint -uv run ruff check . - -# Format -uv run ruff format . - -# Type check -uv run mypy src/ -``` - -## Project Structure - -``` -src/stroke_deepisles_demo/ -├── core/ # Shared utilities (config, types, exceptions) -├── data/ # HF dataset loading and case management -├── inference/ # DeepISLES Docker integration -├── ui/ # Gradio application -├── pipeline.py # End-to-end orchestration -└── metrics.py # Evaluation metrics -``` - -## Pull Request Process - -1. Create a feature branch from `main` -2. Write tests for new functionality -3. Ensure all tests pass and code quality checks pass -4. Update documentation if needed -5. Submit PR with clear description - -## Code Style - -- Type hints on all functions -- Docstrings in Google style -- Keep functions focused and small -- Prefer explicit over implicit -``` - -### `docs/guides/quickstart.md` - -```markdown -# Quickstart - -Get started with stroke-deepisles-demo in 5 minutes. - -## Prerequisites - -- Python 3.11+ -- Docker (for DeepISLES inference) -- ~10GB disk space (for Docker image and datasets) - -## Installation - -```bash -# Clone -git clone https://github.com/The-Obstacle-Is-The-Way/stroke-deepisles-demo.git -cd stroke-deepisles-demo - -# Install -uv sync -``` - -## Pull DeepISLES Docker Image - -```bash -docker pull isleschallenge/deepisles -``` - -## Run Locally - -### Option 1: Gradio UI - -```bash -uv run python -m stroke_deepisles_demo.ui.app -# Open http://localhost:7860 -``` - -### Option 2: CLI - -```bash -# List available cases -uv run stroke-demo list - -# Run on a specific case -uv run stroke-demo run --case sub-001 --fast -``` - -### Option 3: Python API - -```python -from stroke_deepisles_demo.pipeline import run_pipeline_on_case - -result = run_pipeline_on_case("sub-001", fast=True) -print(f"Dice score: {result.dice_score:.3f}") -print(f"Prediction: {result.prediction_mask}") -``` - -## Configuration - -Set environment variables or create a `.env` file: - -```bash -# .env -STROKE_DEMO_LOG_LEVEL=DEBUG -STROKE_DEMO_DEEPISLES_USE_GPU=false # If no GPU available -``` - -See [Configuration Guide](configuration.md) for all options. -``` - -### `docs/guides/configuration.md` - -```markdown -# Configuration - -All settings can be configured via environment variables. - -## Environment Variables - -| Variable | Default | Description | -|----------|---------|-------------| -| `STROKE_DEMO_LOG_LEVEL` | `INFO` | Logging level (DEBUG, INFO, WARNING, ERROR) | -| `STROKE_DEMO_LOG_FORMAT` | `simple` | Log format (simple, detailed, json) | -| `STROKE_DEMO_HF_DATASET_ID` | `YongchengYAO/ISLES24-MR-Lite` | HuggingFace dataset ID | -| `STROKE_DEMO_HF_CACHE_DIR` | `None` | Custom HF cache directory | -| `STROKE_DEMO_HF_TOKEN` | `None` | HuggingFace API token (for private datasets) | -| `STROKE_DEMO_DEEPISLES_DOCKER_IMAGE` | `isleschallenge/deepisles` | DeepISLES Docker image | -| `STROKE_DEMO_DEEPISLES_FAST_MODE` | `true` | Use single-model mode | -| `STROKE_DEMO_DEEPISLES_TIMEOUT_SECONDS` | `1800` | Inference timeout | -| `STROKE_DEMO_DEEPISLES_USE_GPU` | `true` | Use GPU acceleration | -| `STROKE_DEMO_RESULTS_DIR` | `./results` | Directory for output files | - -## Using .env File - -Create a `.env` file in the project root: - -```bash -STROKE_DEMO_LOG_LEVEL=DEBUG -STROKE_DEMO_DEEPISLES_USE_GPU=false -STROKE_DEMO_RESULTS_DIR=/data/results -``` - -## Programmatic Configuration - -```python -from stroke_deepisles_demo.core.config import settings, reload_settings -import os - -# Check current settings -print(settings.log_level) - -# Override via environment -os.environ["STROKE_DEMO_LOG_LEVEL"] = "DEBUG" -reload_settings() -print(settings.log_level) # DEBUG -``` -``` - -## ci configuration - -### `.github/workflows/ci.yml` - -```yaml -name: CI - -on: - push: - branches: [main] - pull_request: - branches: [main] - -jobs: - lint: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Install uv - uses: astral-sh/setup-uv@v4 - - - name: Set up Python - run: uv python install 3.12 - - - name: Install dependencies - run: uv sync - - - name: Lint with ruff - run: uv run ruff check . - - - name: Check formatting - run: uv run ruff format --check . - - typecheck: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Install uv - uses: astral-sh/setup-uv@v4 - - - name: Set up Python - run: uv python install 3.12 - - - name: Install dependencies - run: uv sync - - - name: Type check with mypy - run: uv run mypy src/ - - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Install uv - uses: astral-sh/setup-uv@v4 - - - name: Set up Python - run: uv python install 3.12 - - - name: Install dependencies - run: uv sync - - - name: Run tests - run: uv run pytest --cov --cov-report=xml - - - name: Upload coverage - uses: codecov/codecov-action@v4 - with: - files: ./coverage.xml - - integration: - runs-on: ubuntu-latest - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - steps: - - uses: actions/checkout@v4 - - - name: Install uv - uses: astral-sh/setup-uv@v4 - - - name: Set up Python - run: uv python install 3.12 - - - name: Install dependencies - run: uv sync - - - name: Run integration tests - run: uv run pytest -m integration --timeout=600 -``` - -## final code review checklist - -### code quality -- [ ] All functions have type hints -- [ ] All public functions have docstrings -- [ ] No unused imports or variables -- [ ] No hardcoded paths or secrets -- [ ] Error messages are helpful - -### testing -- [ ] Unit test coverage > 80% -- [ ] Edge cases covered -- [ ] Integration tests for critical paths -- [ ] Tests are deterministic (no flakiness) - -### documentation -- [ ] README is clear and accurate -- [ ] CONTRIBUTING.md is complete -- [ ] All configuration options documented -- [ ] Example usage in docstrings - -### security -- [ ] No secrets in code -- [ ] HF_TOKEN is optional and hidden from logs -- [ ] Docker commands are properly escaped -- [ ] No arbitrary code execution vulnerabilities - -### production readiness -- [ ] Logging is consistent and useful -- [ ] Errors are handled gracefully -- [ ] Configuration is environment-driven -- [ ] CI passes on all checks - -## tdd plan - -### tests for logging - -```python -"""Tests for logging configuration.""" - -from __future__ import annotations - -import logging - -from stroke_deepisles_demo.core.logging import get_logger, setup_logging - - -class TestSetupLogging: - """Tests for setup_logging.""" - - def test_sets_log_level(self) -> None: - """Sets the root logger level.""" - setup_logging("DEBUG") - assert logging.getLogger().level == logging.DEBUG - - def test_format_styles(self) -> None: - """Different format styles work.""" - for style in ["simple", "detailed", "json"]: - setup_logging("INFO", format_style=style) - # Should not raise - - -class TestGetLogger: - """Tests for get_logger.""" - - def test_returns_namespaced_logger(self) -> None: - """Returns logger with stroke_demo prefix.""" - logger = get_logger("my_module") - assert logger.name == "stroke_demo.my_module" -``` - -### tests for configuration - -```python -"""Tests for configuration.""" - -from __future__ import annotations - -import os -from pathlib import Path - -import pytest - -from stroke_deepisles_demo.core.config import Settings, reload_settings - - -class TestSettings: - """Tests for Settings.""" - - def test_default_values(self) -> None: - """Has sensible defaults.""" - settings = Settings() - assert settings.log_level == "INFO" - assert settings.hf_dataset_id == "YongchengYAO/ISLES24-MR-Lite" - - def test_env_override(self, monkeypatch) -> None: - """Environment variables override defaults.""" - monkeypatch.setenv("STROKE_DEMO_LOG_LEVEL", "DEBUG") - settings = Settings() - assert settings.log_level == "DEBUG" - - def test_hf_token_hidden_from_repr(self) -> None: - """HF token is not visible in repr.""" - settings = Settings(hf_token="secret123") - assert "secret123" not in repr(settings) - - def test_results_dir_created(self, tmp_path: Path) -> None: - """Results directory is created if it doesn't exist.""" - new_dir = tmp_path / "new_results" - settings = Settings(results_dir=new_dir) - assert new_dir.exists() -``` - -## "done" criteria - -Phase 5 is complete when: - -1. Structured logging is in place throughout -2. All settings are configurable via environment -3. README.md and CONTRIBUTING.md are complete -4. Developer guides are written -5. CI workflow passes on GitHub Actions -6. Code coverage > 80% overall -7. All code review checklist items pass -8. Repository is ready for others to contribute - -## final deliverables - -At the end of all phases, the repository contains: - -``` -stroke-deepisles-demo/ -├── .github/ -│ └── workflows/ -│ └── ci.yml -├── docs/ -│ ├── specs/ -│ ├── guides/ -│ └── reference/ -├── src/ -│ └── stroke_deepisles_demo/ -│ ├── core/ -│ ├── data/ -│ ├── inference/ -│ ├── ui/ -│ ├── pipeline.py -│ ├── metrics.py -│ └── cli.py -├── tests/ -├── pyproject.toml -├── uv.lock -├── README.md -├── CONTRIBUTING.md -├── CHANGELOG.md -├── .pre-commit-config.yaml -├── .gitignore -├── .env.example -└── app.py # HF Spaces entry point -``` diff --git a/docs/specs/archive/07-hf-spaces-deployment.md b/docs/specs/archive/07-hf-spaces-deployment.md deleted file mode 100644 index ac2b502be6927e9173fb8ecbba115164462f2607..0000000000000000000000000000000000000000 --- a/docs/specs/archive/07-hf-spaces-deployment.md +++ /dev/null @@ -1,969 +0,0 @@ -# spec: hugging face spaces + gradio deployment - -> **Version**: December 2025 -> **Status**: APPROVED - Ready for Implementation -> **Last Updated**: 2025-12-05 -> **Verified**: Cold start claims, pause/restart behavior, ZeroGPU limitations - -## important: gradio 6 is now available - -As of December 2025, **Gradio 6.0.2** is the latest stable release. Our `pyproject.toml` currently specifies `gradio>=5.0.0`, which will install Gradio 6.x. - -**Key breaking changes affecting our codebase:** - -| Change | Impact | Our Code | -|--------|--------|----------| -| `theme`, `css`, `js` moved from `Blocks()` to `launch()` | HIGH | `app.py:111` uses `gr.Blocks()`, `app.py:170` passes theme to `launch()` - **OK** | -| `gr.HTML` padding default `True` → `False` | LOW | No visual impact expected | -| Chatbot tuple format removed | NONE | We don't use Chatbot | -| `show_api` → `footer_links` | LOW | We don't customize this | - -**Recommendation**: Pin to `gradio>=6.0.0,<7.0.0` for stability, or test with latest and update as needed. - -**Migration guide**: [Gradio 6 Migration Guide](https://www.gradio.app/main/guides/gradio-6-migration-guide) - ---- - -## purpose - -This spec documents the requirements, constraints, and best practices for deploying the `stroke-deepisles-demo` Gradio application to Hugging Face Spaces. It identifies potential friction points between our current implementation and HF Spaces constraints, providing concrete guidance before deployment. - -## executive summary - -### critical friction points identified - -| Issue | Severity | Current State | Fix Required | -|-------|----------|---------------|--------------| -| **NVIDIA GPU required** | HIGH | DeepISLES needs CUDA | Use Docker SDK + GPU on HF Spaces | -| **JavaScript in `gr.HTML`** | HIGH | ` - """ -``` - -#### the problem - -From the [Gradio documentation](https://www.gradio.app/guides/custom-CSS-and-JS) and [HF Forums](https://discuss.huggingface.co/t/gradio-html-component-with-javascript-code-dont-work/37316): - -> "The `gr.HTML` component doesn't support loading scripts via traditional ` - """ -``` - -**Note**: Even this may not work reliably. Testing on HF Spaces is required. - -#### alternative: gradio custom components (`gradio cc`) - -For production deployments, Gradio 6 supports first-class **Custom Components** via the `gradio cc` CLI. This is the recommended "production" solution (vs. the `js=` hack for MVP). - -```bash -# Create a NiiVue custom component -gradio cc create NiiVueViewer --template HTML - -# Development server with hot reload -gradio cc dev - -# Build for distribution -gradio cc build - -# Publish to PyPI and HF Spaces -gradio cc publish -``` - -**Pros**: -- First-class support, proper state management -- No hacky string interpolation -- Reusable across projects - -**Cons**: -- Requires Node.js build step -- Higher complexity than `js=` parameter -- Overkill for MVP - -**Source**: [Custom Components In Five Minutes](https://www.gradio.app/guides/custom-components-in-five-minutes) - -#### alternative: `gradio-iframe` component - -The [`gradio-iframe`](https://pypi.org/project/gradio-iframe/) package (v0.0.10) provides an iframe component that may execute JavaScript more reliably: - -```python -from gradio_iframe import iFrame - -viewer = iFrame( - value="...NiiVue code...", - label="NiiVue Viewer" -) -``` - -**Warning**: This is experimental and "not fully tested" per the maintainer. Use with caution. - -### css restrictions - -Custom CSS should use `elem_id` and `elem_classes` rather than query selectors: - -> "The use of query selectors in custom JS and CSS is not guaranteed to work across Gradio versions as the Gradio HTML DOM may change." - -**Source**: [Custom CSS and JS Guide](https://www.gradio.app/guides/custom-CSS-and-JS) - -### security (gradio 5 audit, inherited by v6) - -The Trail of Bits security audit was performed on **Gradio 5.0**. All fixes are inherited by Gradio 6.x: - -- **CVE-2024-47872**: XSS via HTML/JS/SVG file uploads (fixed in 5.0.0) -- File type restrictions enforced server-side -- Our app uses `gradio>=6.0.0` - we're covered - -> **Note**: There was no separate Gradio 6 audit. The security improvements from Gradio 5 persist in v6. - -**Source**: [A Security Review of Gradio 5](https://huggingface.co/blog/gradio-5-security) - ---- - -## readme.md yaml configuration - -### required fields for gradio spaces - -```yaml ---- -title: Stroke DeepISLES Demo -emoji: 🧠 -colorFrom: blue -colorTo: purple -sdk: gradio -sdk_version: "6.0.2" # Latest stable as of Dec 2025 -python_version: "3.11" -app_file: app.py -pinned: false -license: mit -short_description: "Ischemic stroke lesion segmentation using DeepISLES" - -# Optional but recommended -models: - - isleschallenge/deepisles # If we reference it -datasets: - - YongchengYAO/ISLES24-MR-Lite -tags: - - medical-imaging - - stroke - - segmentation - - neuroimaging - - niivue - -# For CPU-only demo mode -suggested_hardware: cpu-basic - -# If we need cross-origin isolation (e.g., SharedArrayBuffer) -# custom_headers: -# cross-origin-embedder-policy: require-corp -# cross-origin-opener-policy: same-origin ---- -``` - -### configuration reference - -| Field | Type | Description | -|-------|------|-------------| -| `sdk` | string | `gradio`, `docker`, or `static` | -| `sdk_version` | string | Gradio version (e.g., "5.0.0") | -| `python_version` | string | Python version (e.g., "3.11") | -| `app_file` | string | Entry point (default: `app.py`) | -| `suggested_hardware` | string | Hardware for duplicators | -| `disable_embedding` | bool | Prevent iframe embedding | -| `custom_headers` | dict | COEP/COOP/CORP headers | - -**Source**: [Spaces Configuration Reference](https://huggingface.co/docs/hub/en/spaces-config-reference) - ---- - -## dependencies - -### requirements.txt for hf spaces - -HF Spaces uses `requirements.txt`, not `pyproject.toml` for dependency installation. - -```text -# requirements.txt for HF Spaces - -# Core - Tobias's fork with BIDS + NIfTI lazy loading -git+https://github.com/CloseChoice/datasets.git@feat/bids-loader-streaming-upload-fix - -# HuggingFace -huggingface-hub>=0.25.0 - -# NIfTI handling -nibabel>=5.2.0 -numpy>=1.26.0 - -# Configuration -pydantic>=2.5.0 -pydantic-settings>=2.1.0 - -# UI - Gradio 6.x (latest stable as of Dec 2025) -gradio>=6.0.0,<7.0.0 -matplotlib>=3.8.0 - -# Networking -requests>=2.0.0 -``` - -### potential issues - -1. **Git dependencies**: HF Spaces supports `git+https://...` in requirements.txt -2. **C extensions**: nibabel/numpy compile fine on HF Spaces -3. **Size**: No bloated dependencies (no PyTorch required for demo mode) - ---- - -## deployment paths - -### hardware requirements - -| Component | Requirement | Notes | -|-----------|-------------|-------| -| GPU | NVIDIA with CUDA 11.3+ | **Mandatory** - no CPU/MPS fallback | -| VRAM | 4GB minimum, 12GB+ recommended | For parallel processing | -| Docker | Docker + nvidia-container-toolkit | Required for DeepISLES | -| Python | 3.8+ (3.11 recommended) | Per project config | - -> ⚠️ **Apple Silicon (M1/M2/M3) is NOT supported.** DeepISLES requires NVIDIA CUDA. - -### path 1: local nvidia gpu (primary development) - -For day-to-day development and testing on your own NVIDIA GPU machine. - -```bash -# 1. Ensure Docker + nvidia-container-toolkit installed -docker run --rm --gpus all nvidia/cuda:11.3-base nvidia-smi - -# 2. Pull DeepISLES image -docker pull isleschallenge/deepisles - -# 3. Run the app -uv run python -m stroke_deepisles_demo.ui.app -``` - -**Pros**: -- Free (you own the hardware) -- Fast iteration -- No network dependency - -**Cons**: -- Requires NVIDIA GPU hardware - -### path 2: hf spaces docker sdk + gpu (on-demand demos) - -For showcasing to others. Spin up when needed, pause when done. - -#### dockerfile for hf spaces - -```dockerfile -# Dockerfile for HF Spaces -# CRITICAL: DeepISLES code lives at /app/src/ in the base image. -# We install our demo at /home/user/demo to avoid overwriting DeepISLES. -FROM isleschallenge/deepisles:latest - -# HF Spaces runs containers with user ID 1000 -RUN useradd -m -u 1000 user 2>/dev/null || true - -# IMPORTANT: Use /home/user/demo for our app, NOT /app -WORKDIR /home/user/demo - -# Add our application -COPY --chown=1000:1000 requirements.txt /home/user/demo/requirements.txt -RUN pip install --no-cache-dir -r requirements.txt - -COPY --chown=1000:1000 pyproject.toml /home/user/demo/pyproject.toml -COPY --chown=1000:1000 src/ /home/user/demo/src/ -COPY --chown=1000:1000 app.py /home/user/demo/app.py -RUN pip install --no-cache-dir --no-deps -e . - -# Environment variables for HF Spaces + direct invocation -ENV HF_SPACES=1 -ENV DEEPISLES_DIRECT_INVOCATION=1 -ENV DEEPISLES_PATH=/app - -USER user -EXPOSE 7860 -ENTRYPOINT [] -CMD ["python", "-m", "stroke_deepisles_demo.ui.app"] -``` - -#### readme.md configuration - -```yaml ---- -title: Stroke DeepISLES Demo -emoji: 🧠 -colorFrom: blue -colorTo: purple -sdk: docker -app_port: 7860 -suggested_hardware: t4-small -pinned: false -license: mit ---- -``` - -#### cost management: pause/restart api - -```python -from huggingface_hub import HfApi - -api = HfApi() -SPACE_ID = "your-username/stroke-deepisles-demo" - -# PAUSE - stops billing immediately -api.pause_space(SPACE_ID) - -# RESTART - spin up for demo -api.restart_space(SPACE_ID) - -# AUTO-SLEEP after 30 min inactivity -api.set_space_sleep_time(SPACE_ID, sleep_time=1800) -``` - -#### billing breakdown - -| State | Billed? | How to Enter | -|-------|---------|--------------| -| Running | ✅ $0.40/hr (T4) | `restart_space()` or visitor wakes it | -| Sleeping | ❌ $0 | Auto after `sleep_time` inactivity | -| Paused | ❌ $0 | `pause_space()` - only owner can restart | - -**Typical demo session**: 30-60 minutes = **$0.20-$0.40** - -**Monthly cost if paused**: **$0.00** - ---- - -## niivue integration analysis - -### current implementation - -Our viewer uses NiiVue loaded from unpkg CDN with base64 data URLs: - -```python -# viewer.py:289-324 -return f""" -
- -
- -""" -``` - -### potential issues - -1. **Script execution**: ` - """ - -# components.py:42 - Basic HTML component without js_on_load -niivue_viewer = gr.HTML(label="Interactive 3D Viewer") # No js_on_load! -``` - -### Why It Fails - -1. `gr.HTML` receives our HTML string as `value` -2. Gradio renders the `
` and `` elements (static HTML) -3. Gradio **strips or ignores** the ` -''' - -demo.launch( - head=NIIVUE_HEAD, - server_name="0.0.0.0", - server_port=7860 -) -``` - -**Pros:** Loads library once, available globally -**Cons:** GitHub Issue #10250 reports unreliable execution - -### Solution 3: Server-Side File Serving - -Instead of base64 data URLs, serve NIfTI files via Gradio's file system: - -```python -# Use Gradio's file URL instead of data URLs -from gradio import FileData -file_data = FileData(path=str(dwi_path)) -# Pass file_data.url to NiiVue instead of base64 -``` - -**Pros:** Avoids 65MB payload, better memory efficiency -**Cons:** Requires refactoring data flow, CORS considerations - -### Solution 4: Custom Gradio Component - -Build a proper `gradio_niivue` package: - -```bash -gradio cc create NiiVue --template HTML -# Implement Svelte frontend with NiiVue -# Publish to PyPI -``` - -**Pros:** Most robust, reusable, proper architecture -**Cons:** Significant development effort - -### Solution 5: Enhanced 2D Fallback (Simplest) - -Remove NiiVue entirely, enhance matplotlib visualization: - -```python -def create_results_display(): - with gr.Group(): - # Remove: niivue_viewer = gr.HTML(...) - - # Enhanced 2D visualization - slice_plot = gr.Plot(label="Multi-View Comparison") - slice_slider = gr.Slider(label="Slice", minimum=0, maximum=100) - - # Add orthogonal views - with gr.Row(): - axial_plot = gr.Plot(label="Axial") - coronal_plot = gr.Plot(label="Coronal") - sagittal_plot = gr.Plot(label="Sagittal") -``` - -**Pros:** Eliminates WebGL complexity, works reliably -**Cons:** Loses 3D interactivity, less impressive demo - ---- - -## Investigation Steps - -### Step 0: Test Async/Await in js_on_load (CRITICAL) -Before implementing Solution 1, verify async works: -```python -import gradio as gr - -with gr.Blocks() as demo: - html = gr.HTML( - value="
Testing async...
", - js_on_load=""" - (async () => { - element.innerText = 'Async started...'; - await new Promise(r => setTimeout(r, 1000)); - element.innerText = 'Async works!'; - element.style.background = 'green'; - })(); - """ - ) - -demo.launch() -``` - -If this shows "Async works!" with green background after 1 second, async is supported. - -### Step 1: Verify js_on_load Works (Basic) -Create minimal test: -```python -import gradio as gr - -with gr.Blocks() as demo: - html = gr.HTML( - value="
Loading...
", - js_on_load="element.style.background='green'; element.innerText='JS Works!';" - ) - -demo.launch() -``` - -### Step 2: Test Dynamic Import in js_on_load -```python -js_on_load=""" - (async () => { - const mod = await import('https://unpkg.com/@niivue/niivue@0.65.0/dist/index.js'); - console.log('NiiVue loaded:', mod); - element.innerText = 'Import succeeded!'; - })(); -""" -``` - -### Step 3: Check Browser Console -1. Open HF Spaces demo -2. Open DevTools (F12) → Console -3. Look for errors related to: - - Module loading failures - - WebGL context issues - - CORS errors - - Memory errors - -### Step 4: Test with Smaller Files -Create downsampled test NIfTI (~1MB) to isolate size vs JS issues. - ---- - -## Related Issues - -- **Bug #9**: DeepISLES modules not found (FIXED - subprocess bridge) -- **Bug #8**: HF Spaces streaming hang (FIXED) -- **Technical Debt**: NiiVue memory overhead (P2) -- **[Gradio #4511](https://github.com/gradio-app/gradio/issues/4511)**: 3D medical image support request (closed, not planned) -- **[Gradio #10250](https://github.com/gradio-app/gradio/issues/10250)**: JS in head param issues (open) - ---- - -## Priority Assessment - -**Severity:** P2 (Medium) -- Core inference pipeline works correctly -- 2D visualization provides adequate fallback -- No data loss or security impact -- Demo is functional for evaluation purposes - -**Impact:** -- Less impressive without 3D viewer -- Users can still evaluate predictions via 2D slices -- Download functionality unaffected - -**Recommendation:** -1. First, validate inference accuracy across multiple cases -2. Then attempt Solution 1 (js_on_load) as quick fix -3. If that fails, implement Solution 5 (enhanced 2D) for reliability -4. Consider Solution 4 (custom component) for future enhancement - ---- - -## References - -- [Gradio HTML Docs](https://www.gradio.app/docs/gradio/html) -- [Gradio Custom HTML Components Guide](https://www.gradio.app/guides/custom_HTML_components) -- [Gradio 6 Migration Guide](https://www.gradio.app/main/guides/gradio-6-migration-guide) -- [HuggingFace Forum: JS doesn't work in gr.HTML](https://discuss.huggingface.co/t/gradio-html-component-with-javascript-code-dont-work/37316) -- [GitHub Issue #10250: JS in head param](https://github.com/gradio-app/gradio/issues/10250) -- [GitHub Issue #4511: 3D Medical Images](https://github.com/gradio-app/gradio/issues/4511) -- [NiiVue GitHub](https://github.com/niivue/niivue) -- [ipyniivue (Jupyter Widget)](https://github.com/niivue/ipyniivue) -- [Gradio 6 Announcement](https://alternativeto.net/news/2025/11/gradio-6-released-with-faster-performance-for-creating-machine-learning-apps-in-python/) - ---- - -## Appendix: HF Spaces Logs - -```text -INFO: Running segmentation for sub-stroke0002 -INFO: Case sub-stroke0002 ready: DWI=20.9MB, ADC=12.6MB -INFO: DeepISLES subprocess completed in 30.88s -``` - -Note: No JavaScript errors visible in server logs (client-side only). diff --git a/docs/specs/archive/11-bug-niivue-js-on-load-not-rerunning.md b/docs/specs/archive/11-bug-niivue-js-on-load-not-rerunning.md deleted file mode 100644 index bd3d92c0f1f759f3f5ec14ab22166378d4f5f53a..0000000000000000000000000000000000000000 --- a/docs/specs/archive/11-bug-niivue-js-on-load-not-rerunning.md +++ /dev/null @@ -1,484 +0,0 @@ -# Bug #11: NiiVue js_on_load Doesn't Re-run on Value Update - -## Status: FIXED - -**Date:** 2025-12-09 -**Branch:** `fix/niivue-js-rerun` -**Fixed By:** Implementing `.then(fn=None, js=NIIVUE_UPDATE_JS)` pattern with correct `document.querySelector` context. -**Related:** Bug #10 (Fixed) - ---- - -## TL;DR - ROOT CAUSE - -**Gradio's `js_on_load` only runs ONCE when the component first mounts.** - -When we update the `gr.HTML` value with new content (after segmentation), the `js_on_load` code does NOT re-execute. The HTML updates, but the JavaScript initialization never runs. - ---- - -## Symptom - -After successful DeepISLES inference on HF Spaces: -- Viewer shows "Loading viewer..." (initial HTML state) -- Status never changes to "Checking WebGL2..." or "Loading NiiVue..." -- No error message displayed -- No brain scan visible - -**What IS working:** -- DeepISLES inference completes (~36 seconds) -- Slice Comparison (matplotlib 2D view) renders correctly -- Metrics JSON displays correctly -- Download button provides the prediction mask -- Initial HTML renders with data-* attributes - -**What is NOT working:** -- js_on_load JavaScript doesn't re-run when value updates -- NiiVue never initializes after segmentation - ---- - -## Evidence - -### Gradio Documentation - -From [Custom HTML Components Guide](https://www.gradio.app/guides/custom_HTML_components): - -> "Event listeners attached in `js_on_load` are only attached **once** when the component is first rendered. If your component creates new elements dynamically that need event listeners, attach the event listener to a parent element..." - -### Observed Behavior - -1. Page loads → js_on_load runs → No volumeUrl → Shows "Waiting for segmentation..." -2. User clicks "Run Segmentation" -3. DeepISLES runs successfully -4. `run_segmentation()` returns new HTML with data-volume-url attribute -5. gr.HTML value updates with new HTML -6. **js_on_load does NOT re-run** ← THE BUG -7. Viewer shows "Loading viewer..." (static HTML, no JS executed) - -### Server Logs (Working) - -```text -INFO: Running segmentation for sub-stroke0001 -INFO: DeepISLES subprocess completed in 35.73s -``` - -Inference works. The problem is client-side JavaScript execution. - ---- - -## Code Flow Analysis - -### Current Implementation (BROKEN) - -```python -# components.py - js_on_load set once at component creation -niivue_viewer = gr.HTML( - label="Interactive 3D Viewer", - js_on_load=NIIVUE_ON_LOAD_JS, # Runs ONCE on mount -) - -# app.py - returns new HTML value after segmentation -def run_segmentation(...): - # ... inference ... - niivue_html = create_niivue_html(dwi_url, mask_url) - return niivue_html, ... # Value updates, but js_on_load doesn't re-run -``` - -### Why It Fails - -1. Component mounts → js_on_load runs (no data yet) -2. Value updates → HTML re-renders, js_on_load SKIPPED -3. New HTML has data-* attributes but no JS execution - ---- - -## Proposed Solutions (Ranked) - -### Solution 1: Use `js` Parameter on Event Handler (Recommended) - -Gradio allows running JavaScript after an event completes: - -```python -run_btn.click( - fn=run_segmentation, - inputs=[...], - outputs=[results["niivue_viewer"], ...], -).then( - fn=None, # MUST be explicit! - js=NIIVUE_UPDATE_JS, # ⚠️ CANNOT reuse NIIVUE_ON_LOAD_JS - different context! -) -``` - -**Pros:** Native Gradio pattern, runs after each update -**Cons:** Requires separate JS constant (see "Different JS Context" section below) - -**⚠️ CRITICAL:** The `js` param does NOT have access to `element`. You must use -`document.querySelector()` instead. See the corrected JavaScript in the -"Recommended Implementation" section. - -### Solution 2: MutationObserver in js_on_load - -Watch for DOM changes and re-initialize. This approach IS valid because -`js_on_load` has access to `element`: - -```javascript -// In js_on_load - 'element' IS available here -const initNiiVue = async () => { - const container = element.querySelector('.niivue-viewer') || element; - const volumeUrl = container.dataset.volumeUrl; - if (!volumeUrl) return; - // ... NiiVue initialization code ... -}; - -// Watch for attribute changes (when Python updates data-volume-url) -const observer = new MutationObserver((mutations) => { - for (const mutation of mutations) { - if (mutation.type === 'attributes' && - mutation.attributeName.startsWith('data-')) { - initNiiVue(); - break; - } - } -}); - -// Observe the element for attribute changes -observer.observe(element, { - attributes: true, - subtree: true, - attributeFilter: ['data-volume-url', 'data-mask-url'] -}); - -// Initial check -initNiiVue(); -``` - -**Pros:** Self-contained in js_on_load, no separate event wiring needed -**Cons:** More complex, relies on Gradio updating DOM attributes (may not work -if Gradio replaces the entire element instead of updating attributes) - -### Solution 3: Use gradio-iframe Component - -The `gradio-iframe` package allows JavaScript to execute normally: - -```python -from gradio_iframe import iFrame - -niivue_viewer = iFrame( - value=create_niivue_html_with_script(...), # Scripts execute in iframe -) -``` - -**Pros:** Scripts execute normally inside iframe -**Cons:** Additional dependency, iframe quirks - -### Solution 4: Embed JS in HTML via data: URL iframe - -Self-contained iframe with script: - -```python -def create_niivue_html(...): - html_with_script = f'''...''' - encoded = base64.b64encode(html_with_script.encode()).decode() - return f'' -``` - -**Pros:** No external dependency, scripts execute -**Cons:** Complex, potential CSP issues - -### Solution 5: Custom Gradio Component - -Build a proper `gradio_niivue` Svelte component: - -```bash -gradio cc create NiiVue --template HTML -``` - -**Pros:** Most robust, proper lifecycle hooks -**Cons:** Significant development effort - ---- - -## Investigation Steps - -### Step 1: Test Solution 1 (js param on .then()) - -```python -run_btn.click( - fn=run_segmentation, - inputs=[...], - outputs=[...], -).then( - fn=None, - js="console.log('then JS ran'); console.log(document.querySelector('.niivue-viewer'));" -) -``` - -Verify: -- Does `js` run after value update? -- Does it have access to the updated DOM? - -### Step 2: Test Solution 2 (MutationObserver) - -Add observer to js_on_load and check if it triggers on value change. - -### Step 3: Check Browser Console - -Open DevTools and look for: -- JavaScript errors -- Console logs from js_on_load -- Network requests to NiiVue CDN - ---- - -## Temporary Workaround - -The 2D Slice Comparison view works correctly and provides adequate visualization for evaluation purposes while we fix the 3D viewer. - ---- - -## Priority Assessment - -**Severity:** P1 (High) -- 3D viewer is a key feature for the demo -- The fix we deployed doesn't fully work -- Blocks demo usability for 3D visualization - -**Impact:** -- Users see "Loading viewer..." indefinitely -- 2D fallback still works -- Demo is partially functional - ---- - -## Deep Web Research (2025-12-09) - -### Relationship Between Bug #10 and Bug #11 - -**They are the SAME underlying issue with two symptoms:** - -1. **Bug #10**: Gradio strips `' -) as demo: - ... -``` - -Or use the global `js` parameter on `gr.Blocks` to define initialization code that runs after the script loads. diff --git a/docs/specs/archive/AUDIT_JS_LOADING_ISSUES.md b/docs/specs/archive/AUDIT_JS_LOADING_ISSUES.md deleted file mode 100644 index 80a6b0c8e926efc983da03cb5241660d4de114a9..0000000000000000000000000000000000000000 --- a/docs/specs/archive/AUDIT_JS_LOADING_ISSUES.md +++ /dev/null @@ -1,935 +0,0 @@ -# Comprehensive Audit: JavaScript Loading Issues on HuggingFace Spaces - -**Created:** 2025-12-09 -**Status:** P0 - Critical -**Issue:** HF Spaces stuck on "Loading..." forever despite "Running on T4" - ---- - -## Executive Summary - -The NiiVue 3D viewer fails to load on HuggingFace Spaces due to a combination of JavaScript loading issues, timing race conditions, and architectural problems. This document catalogs EVERY potential issue found in the codebase. - ---- - -## ROOT CAUSES IDENTIFIED - -### 1. Module Script Timing Race Condition (CRITICAL) - -**Location:** `src/stroke_deepisles_demo/ui/viewer.py:64-68` - -```python -loader_content = f"""... - -""" -``` - -**Problem:** ` -``` - -### P3: Bundle NiiVue into a Single IIFE - -Create a self-contained bundle that doesn't need ES module import. - ---- - -## FILES TO AUDIT BEFORE ANY FIX - -1. `src/stroke_deepisles_demo/ui/viewer.py` - All JS constants -2. `src/stroke_deepisles_demo/ui/components.py` - js_on_load usage -3. `src/stroke_deepisles_demo/ui/app.py` - .then(js=) usage, launch config -4. `app.py` - launch config -5. `.gitignore` - niivue-loader.html entry -6. `Dockerfile` - CMD entry point - ---- - -## VERSION HISTORY - -| Date | Change | Result | -|------|--------|--------| -| Pre-bc1d8e8 | Inline `` - -### Files Involved - -| File | Purpose | Status | -|------|---------|--------| -| `app.py` (root) | HF Spaces entry point | Uses head_paths ✅ | -| `src/.../ui/app.py` | Main UI module | Uses js_on_load ✅ | -| `src/.../ui/viewer.py` | NiiVue loader generation | Generates at runtime ✅ | -| `src/.../ui/components.py` | UI components | Uses NIIVUE_ON_LOAD_JS ✅ | -| `src/.../ui/assets/niivue.js` | Vendored NiiVue library | 2.9MB, tracked ✅ | -| `src/.../ui/assets/niivue-loader.html` | Generated loader | gitignored ✅ | - -### Dockerfile CMD - -```dockerfile -CMD ["python", "-m", "stroke_deepisles_demo.ui.app"] -``` - -This runs `ui/app.py` as `__main__`, which should execute our launch() with head_paths. - ---- - -## Hypotheses - -### H1: `js_on_load` Breaking Gradio Initialization - -**Theory**: The `js_on_load` parameter on `gr.HTML` might be executing before Gradio fully initializes, causing a crash. - -**Evidence**: Our code has `js_on_load=NIIVUE_ON_LOAD_JS` which is a complex async IIFE. - -**Test**: Remove `js_on_load` parameter and see if app loads. - -### H2: `head_paths` Not Being Applied on HF Spaces - -**Theory**: The `head_paths` parameter might not be reaching the frontend on HF Spaces due to Docker networking or Gradio configuration. - -**Evidence**: Issue #11649 shows head-related parameters have bugs. - -**Test**: Check browser Network tab for niivue.js 404 or missing script. - -### H3: demo.load() Blocking Initial Render - -**Theory**: The `demo.load(initialize_case_selector, ...)` call might be blocking the initial UI render. - -**Evidence**: `initialize_case_selector()` calls `list_case_ids()` which loads HuggingFace dataset. - -**Test**: Remove demo.load() and see if app loads. - -### H4: Double set_static_paths Causing Conflict - -**Theory**: Both `app.py` (root) and `ui/app.py` call `gr.set_static_paths()`. This might cause conflicts. - -**Evidence**: Gradio docs say "affects ALL Gradio apps in same interpreter session". - -**Test**: Remove one of the set_static_paths calls. - -### H5: Module Import Order Issue - -**Theory**: The order of imports and set_static_paths calls might matter on HF Spaces but not locally. - -**Evidence**: We have `noqa: E402` comments indicating non-standard import order. - -**Test**: Trace exact import order and when set_static_paths is effective. - -### H6: Path Resolution Different in Docker - -**Theory**: `Path(__file__).resolve()` might resolve to different paths in Docker vs local. - -**Evidence**: We use absolute paths for NIIVUE_JS_URL computed at import time. - -**Test**: Log the actual paths being computed in Docker. - ---- - -## Diagnostic Steps to Try - -1. **Minimal Test**: Create a branch that removes ALL custom JS and test if basic Gradio loads -2. **Log Paths**: Add logging to show exactly what paths are computed in Docker -3. **Browser DevTools**: Check Network tab and Console for errors (if accessible) -4. **Gradio Version**: Verify we're using a version with all relevant fixes -5. **HF Spaces Logs**: Check full container logs for any Python errors not shown in UI - ---- - -## Related Documentation - -- [AUDIT_JS_LOADING_ISSUES.md](./AUDIT_JS_LOADING_ISSUES.md) - Previous audit of JavaScript loading issues -- [docs/specs/24-bug-hf-spaces-loading-forever.md](./docs/specs/24-bug-hf-spaces-loading-forever.md) - Original bug specification - ---- - -## External Resources - -- [Gradio Custom CSS and JS Guide](https://www.gradio.app/guides/custom-CSS-and-JS) -- [Gradio File Access Guide](https://www.gradio.app/guides/file-access) -- [Gradio set_static_paths Docs](https://www.gradio.app/docs/gradio/set_static_paths) -- [Gradio Issue #11649](https://github.com/gradio-app/gradio/issues/11649) - head_paths solution -- [Gradio Issue #10250](https://github.com/gradio-app/gradio/issues/10250) - head JS not executing -- [Gradio Issue #6426](https://github.com/gradio-app/gradio/issues/6426) - head argument bugs -- [HF Spaces Docker Guide](https://huggingface.co/docs/hub/spaces-sdks-docker) diff --git a/docs/specs/archive/ROOT_CAUSE_ANALYSIS.md b/docs/specs/archive/ROOT_CAUSE_ANALYSIS.md deleted file mode 100644 index 5a2e44743d3979a50bf340ff5b604923feefcffb..0000000000000000000000000000000000000000 --- a/docs/specs/archive/ROOT_CAUSE_ANALYSIS.md +++ /dev/null @@ -1,230 +0,0 @@ -# Root Cause Analysis: HF Spaces "Loading..." Forever (Issue #24) - -**Date:** 2025-12-10 -**Status:** IN PROGRESS -**Branch:** `debug/niivue-head-script-loading` - ---- - -## Executive Summary - -The HuggingFace Spaces app hangs on "Loading..." indefinitely because **dynamic ES module `import()` inside `gr.HTML(js_on_load=...)` blocks Gradio's Svelte frontend from hydrating**. - -This was proven empirically in our own A/B test documented in `docs/specs/24-bug-hf-spaces-loading-forever.md`: - -> **Diagnostic test:** Disabled `js_on_load` parameter entirely. -> **Result:** App loads perfectly! Everything works EXCEPT Interactive 3D viewer. - ---- - -## First Principles Analysis - -### How Gradio Renders - -1. Server sends initial HTML with loading spinner -2. Gradio's Svelte app downloads and hydrates -3. Components mount, including `gr.HTML` -4. `js_on_load` executes during component mount -5. Loading spinner clears when hydration completes - -### Why Dynamic Import Blocks Hydration - -The current `js_on_load` code does this (viewer.py:497): - -```javascript -const module = await import(niivueUrl); // <-- BLOCKS HYDRATION -window.Niivue = module.Niivue; -``` - -**Problem:** If this `import()` hangs (CSP issues, network issues, MIME type issues, etc.), the async IIFE never resolves, and Svelte's mount lifecycle stalls. HF Spaces silently blocks or delays these imports. - -### Why `head=` Works - -The `head=` parameter injects content into `` BEFORE Gradio hydrates: - -```html - - - - -``` - -**Key insight:** Even if this script fails, Gradio still loads because: -1. Script tags in `` don't block Svelte hydration -2. They run BEFORE Gradio components mount -3. Failure just means `window.Niivue` is undefined (graceful degradation) - -Then `js_on_load` simply USES `window.Niivue` (no imports): - -```javascript -// No import() - just use what's already loaded -const Niivue = window.Niivue; -if (!Niivue) { - // Show error message, don't block -} -``` - ---- - -## Evidence-Based Conclusions - -| Claim | Evidence | Validated | -|-------|----------|-----------| -| Dynamic `import()` in `js_on_load` blocks HF Spaces | A/B test: disabling `js_on_load` makes app load | **YES** | -| Vendored NiiVue file is served correctly | Local testing shows 200 response | **YES** | -| `gr.set_static_paths()` is called correctly | Called before any Blocks in both entry points | **YES** | -| `allowed_paths` is configured correctly | Both entry points pass `allowed_paths` | **YES** | -| `demo.load()` doesn't block initial render | Gradio docs confirm load runs post-hydration | **YES** | - ---- - -## The Fix - -### Before (Broken) - -```python -# viewer.py - NIIVUE_ON_LOAD_JS -const module = await import(niivueUrl); # Dynamic import in js_on_load -window.Niivue = module.Niivue; -``` - -```python -# ui/app.py - No head= parameter, relying on js_on_load to load NiiVue -demo.launch(...) # No head= parameter -``` - -### After (Fixed) - -```python -# ui/app.py - Load NiiVue via head= BEFORE Gradio hydrates -from stroke_deepisles_demo.ui.viewer import get_niivue_head_html - -get_demo().launch( - head=get_niivue_head_html(), # Inject NiiVue loader into - ... -) -``` - -```python -# viewer.py - NIIVUE_ON_LOAD_JS just USES window.Niivue (no import) -const Niivue = window.Niivue; -if (!Niivue) { - // Graceful error - don't block Gradio - container.innerHTML = 'NiiVue failed to load...'; - return; -} -``` - ---- - -## Why Previous Attempts Failed - -### Attempt 1: CDN Import -**Failed because:** HF Spaces CSP blocks external CDN imports - -### Attempt 2: Vendor NiiVue + Dynamic Import in js_on_load -**Failed because:** Dynamic `import()` in js_on_load still blocks Svelte hydration, even for local files - -### Attempt 3: Remove head= and make js_on_load self-sufficient -**Failed because:** This approach doubled down on the broken pattern (dynamic import in js_on_load) - -### This Fix: head= for loading + js_on_load for init only -**Should work because:** Matches the architecture documented in spec 24 and proven by the A/B test - ---- - -## Test Strategy - -1. **Local sanity:** Run with fix, verify app loads and NiiVue works -2. **A/B comparison:** Compare behavior with/without `head=` parameter -3. **HF Spaces deployment:** Push to hf-personal remote and verify -4. **Console inspection:** Check for `[NiiVue Loader]` logs in browser console - ---- - -## Files to Modify - -| File | Change | -|------|--------| -| `src/stroke_deepisles_demo/ui/viewer.py` | Remove `import()` from js_on_load, use `window.Niivue` directly | -| `src/stroke_deepisles_demo/ui/app.py` | Add `head=get_niivue_head_html()` to launch() | -| `app.py` | Same as above for local dev | - ---- - -## Update (2025-12-10): Web Research Findings - -### Critical Discovery: The Issue is Gradio, NOT HuggingFace Spaces - -**Web search confirmed:** -- HF Spaces DOES support JavaScript, WebGL, ES modules -- Working examples: Unity WebGL, Three.js games, Gaussian Splat Viewer -- The issue is specifically **Gradio's handling of custom JavaScript** - -**Sources:** -- [HF Unity WebGL Template](https://github.com/huggingface/Unity-WebGL-template-for-Hugging-Face-Spaces) -- [WebGL Gaussian Splat Viewer on HF](https://huggingface.co/spaces/cakewalk/splat) -- [HF Forum: Gradio HTML with JS doesn't work](https://discuss.huggingface.co/t/gradio-html-component-with-javascript-code-dont-work/37316) - -### Known Gradio Limitations - -1. **`gr.HTML()` cannot load ` + + +``` + +### frontend/README.md (HuggingFace Spaces Config) + +```markdown +--- +title: Stroke Lesion Viewer +emoji: 🧠 +colorFrom: blue +colorTo: purple +sdk: static +app_file: dist/index.html +app_build_command: npm run build +# CRITICAL: Vite 6 requires Node.js >= 20. HF Spaces defaults to Node 18. +# Without this, the build will fail or produce warnings. +nodejs_version: "20" +pinned: false +--- + +# Stroke Lesion Segmentation Viewer + +Interactive 3D viewer for stroke lesion segmentation results using NiiVue. + +Built with React, TypeScript, Tailwind CSS, and Vite. +``` + +--- + +## Backend Implementation + +### requirements.txt + +``` +fastapi==0.124.2 +uvicorn[standard]==0.34.0 +pydantic==2.10.4 +python-multipart>=0.0.18 + +# Existing project dependencies +stroke-deepisles-demo @ file:. +``` + +**Why these exact versions (Dec 2025):** +- `fastapi` **0.124.2**: Latest stable (Dec 10, 2025) +- `uvicorn[standard]` **0.34.0**: Latest stable +- `pydantic` **2.10.4**: Latest stable +- `python-multipart` **>=0.0.18**: Required by FastAPI 0.124.x + +### backend/api/main.py + +```python +"""FastAPI backend for stroke segmentation.""" + +import os +import re + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles + +from api.routes import router + +app = FastAPI( + title="Stroke Segmentation API", + description="DeepISLES stroke lesion segmentation", + version="1.0.0", +) + +# CORS for frontend - HF Spaces use dashed hostnames: {org}--{space}.hf.space +# Also supports PR previews: pr-{n}--{org}--{space}.hf.space +FRONTEND_ORIGIN = os.environ.get("FRONTEND_ORIGIN", "") +CORS_ORIGINS = [ + "http://localhost:5173", # Local Vite dev server + "http://localhost:3000", # Alternative local port +] +if FRONTEND_ORIGIN: + CORS_ORIGINS.append(FRONTEND_ORIGIN) + +app.add_middleware( + CORSMiddleware, + allow_origins=CORS_ORIGINS, + # Regex matches HuggingFace Spaces origins: + # - Production: https://{org}--stroke-viewer-frontend.hf.space + # - PR preview: https://{org}--stroke-viewer-frontend--pr-{N}.hf.space + # - Branch: https://{org}--stroke-viewer-frontend--{branch}.hf.space + # Pattern: anything--stroke-viewer-frontend, optionally followed by --anything + allow_origin_regex=r"https://.*--stroke-viewer-frontend(--.*)?\.hf\.space", + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# API routes +app.include_router(router, prefix="/api") + +# Serve NIfTI files from results directory +# Files are stored as /tmp/stroke-results/{run_id}/{case_id}/{filename} +app.mount("/files", StaticFiles(directory="/tmp/stroke-results"), name="files") + + +@app.get("/") +async def root(): + return {"status": "healthy", "service": "stroke-segmentation-api"} +``` + +### backend/api/routes.py + +```python +"""API route handlers.""" + +import os +import uuid +from pathlib import Path + +from fastapi import APIRouter, HTTPException, Request +from api.schemas import SegmentRequest, SegmentResponse, CasesResponse + +from stroke_deepisles_demo.data import list_case_ids +from stroke_deepisles_demo.pipeline import run_pipeline_on_case +from stroke_deepisles_demo.metrics import compute_volume_ml + +router = APIRouter() + +# Base directory for results (must match StaticFiles mount in main.py) +RESULTS_BASE = Path("/tmp/stroke-results") + + +def get_backend_base_url(request: Request) -> str: + """Get the backend's public URL for building absolute file URLs. + + Priority: + 1. BACKEND_PUBLIC_URL env var (for production HF Spaces) + 2. Request's base URL (for local development) + """ + env_url = os.environ.get("BACKEND_PUBLIC_URL", "").rstrip("/") + if env_url: + return env_url + # Fall back to request origin (works for local dev) + return str(request.base_url).rstrip("/") + + +@router.get("/cases", response_model=CasesResponse) +async def get_cases(): + """List available cases from dataset.""" + try: + cases = list_case_ids() + return CasesResponse(cases=cases) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/segment", response_model=SegmentResponse) +async def run_segmentation(request: Request, body: SegmentRequest): + """Run DeepISLES segmentation on a case.""" + try: + # Generate unique run ID to avoid conflicts between concurrent requests + run_id = str(uuid.uuid4())[:8] + output_dir = RESULTS_BASE / run_id + + result = run_pipeline_on_case( + body.case_id, + output_dir=output_dir, + fast=body.fast_mode, + compute_dice=True, + cleanup_staging=True, + ) + + # Compute volume + volume_ml = None + try: + volume_ml = round(compute_volume_ml(result.prediction_mask, threshold=0.5), 2) + except Exception: + pass + + # Build ABSOLUTE file URLs for cross-origin NiiVue loading + # Files are at: /tmp/stroke-results/{run_id}/{case_id}/{filename} + # Served at: /files/{run_id}/{case_id}/{filename} + backend_url = get_backend_base_url(request) + dwi_filename = result.input_files["dwi"].name + pred_filename = result.prediction_mask.name + + # URL path: /files/{run_id}/{case_id}/{filename} + file_path_prefix = f"/files/{run_id}/{result.case_id}" + + return SegmentResponse( + caseId=result.case_id, + diceScore=result.dice_score, + volumeMl=volume_ml, + elapsedSeconds=round(result.elapsed_seconds, 2), + dwiUrl=f"{backend_url}{file_path_prefix}/{dwi_filename}", + predictionUrl=f"{backend_url}{file_path_prefix}/{pred_filename}", + ) + + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) +``` + +### backend/api/schemas.py + +```python +"""Pydantic schemas for API.""" + +from pydantic import BaseModel + + +class CasesResponse(BaseModel): + cases: list[str] + + +class SegmentRequest(BaseModel): + case_id: str + fast_mode: bool = True + + +class SegmentResponse(BaseModel): + caseId: str + diceScore: float | None + volumeMl: float | None + elapsedSeconds: float + dwiUrl: str + predictionUrl: str +``` + +### backend/Dockerfile + +```dockerfile +# CRITICAL: Must use isleschallenge/deepisles base image +# This image contains: +# - PyTorch with CUDA support +# - Pre-installed DeepISLES model weights (~18GB) +# - All medical imaging dependencies (nibabel, nnunet, etc.) +# +# Using python:3.11-slim would require manually downloading weights +# and reinstalling all CUDA/PyTorch dependencies - not feasible. +FROM isleschallenge/deepisles:latest + +WORKDIR /app + +# Copy the ENTIRE project (stroke-deepisles-demo package) +# This is required because requirements.txt references "stroke-deepisles-demo @ file:." +COPY pyproject.toml . +COPY src/ src/ +COPY README.md . + +# Copy API code +COPY backend/api/ api/ +COPY backend/requirements.txt . + +# Install API dependencies (FastAPI, uvicorn) + local package +# Note: Base image already has torch, nibabel, etc. +RUN pip install --no-cache-dir -r requirements.txt + +# Create results directory (used by StaticFiles mount) +RUN mkdir -p /tmp/stroke-results + +# Environment variables for HuggingFace Spaces +ENV HF_SPACES=1 +ENV DEEPISLES_DIRECT_INVOCATION=1 + +# Expose port (HF Spaces expects 7860) +EXPOSE 7860 + +# Run FastAPI +CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "7860"] +``` + +**CRITICAL: GPU Required** + +DeepISLES requires GPU acceleration. HuggingFace Spaces FREE tier (`cpu-basic`) will NOT work. + +| Tier | GPU | Will Work? | +|------|-----|------------| +| `cpu-basic` (free) | None | ❌ No | +| `t4-small` | NVIDIA T4 (16GB) | ✅ Yes | +| `t4-medium` | NVIDIA T4 (16GB) | ✅ Yes | +| `a10g-small` | NVIDIA A10G (24GB) | ✅ Yes | + +When creating the HF Space, select **T4-small** or higher. + +**Note:** The Dockerfile copies the full project because `requirements.txt` has: +``` +stroke-deepisles-demo @ file:. +``` +This PEP 508 local path reference requires the package source to be present. + +### backend/README.md (HuggingFace Spaces Config) + +```markdown +--- +title: Stroke Segmentation API +emoji: 🧠 +colorFrom: blue +colorTo: purple +sdk: docker +app_port: 7860 +pinned: false +--- + +# Stroke Segmentation API + +FastAPI backend running DeepISLES stroke lesion segmentation. + +## Endpoints + +- `GET /api/cases` - List available cases +- `POST /api/segment` - Run segmentation +- `GET /files/{filename}` - Download result files +``` + +--- + +## Setup Commands + +### Frontend (Local Development) + +```bash +# Create project +npm create vite@latest stroke-viewer-frontend -- --template react-ts +cd stroke-viewer-frontend + +# Install dependencies +npm install @niivue/niivue +npm install -D tailwindcss @tailwindcss/vite + +# Copy the files from this spec into src/ + +# Run dev server +npm run dev +# Opens http://localhost:5173 +``` + +### Backend (Local Development) + +```bash +cd backend + +# Create virtual environment +python -m venv venv +source venv/bin/activate + +# Install dependencies +pip install -r requirements.txt + +# Run server +uvicorn api.main:app --reload --port 7860 +# Opens http://localhost:7860 +``` + +### Deploy to HuggingFace + +```bash +# Frontend (Static Space) +cd frontend +huggingface-cli repo create stroke-viewer-frontend --type space --space-sdk static +huggingface-cli upload stroke-viewer-frontend ./dist . --repo-type space + +# Backend (Docker Space) +cd backend +huggingface-cli repo create stroke-viewer-api --type space --space-sdk docker +huggingface-cli upload stroke-viewer-api . . --repo-type space +``` + +--- + +## Environment Variables + +### Frontend (.env) + +```env +VITE_API_URL=https://your-username-stroke-viewer-api.hf.space +``` + +### Backend + +No additional env vars needed - uses existing stroke-deepisles-demo configuration. + +--- + +## Key Differences from Gradio + +| What | Gradio (broken) | This Stack | +|------|-----------------|------------| +| NiiVue JavaScript | Blocked by innerHTML | Full execution ✓ | +| WebGL2 context | Frozen during hydration | Works normally ✓ | +| Bundle size | ~2MB Gradio overhead | ~200KB total | +| Cold start | Python + Gradio init | Instant (static) | +| Customization | Limited to Gradio components | Full React control | + +--- + +## Next Steps + +1. Create `frontend/` directory with files from this spec +2. Create `backend/` directory with files from this spec +3. Test locally: `npm run dev` + `uvicorn api.main:app` +4. Create HuggingFace Spaces (one Static, one Docker) +5. Deploy and test + +--- + +## Dependencies Summary (Verified Dec 11, 2025) + +**Frontend (npm) - PINNED VERSIONS:** +| Package | Version | Notes | +|---------|---------|-------| +| react | 18.3.1 | NOT React 19 (CVE-2025-55182) | +| react-dom | 18.3.1 | Must match react version | +| @niivue/niivue | 0.65.0 | Latest stable | +| typescript | 5.6.3 | Latest 5.6.x | +| vite | 6.0.5 | Stable v6 (not v7/v8 beta) | +| tailwindcss | 4.1.7 | Latest v4 | +| @tailwindcss/vite | 4.1.7 | Must match tailwindcss | +| @vitejs/plugin-react | 4.3.4 | Latest stable | + +**Backend (pip) - PINNED VERSIONS:** +| Package | Version | Notes | +|---------|---------|-------| +| fastapi | 0.124.2 | Latest (Dec 10, 2025) | +| uvicorn[standard] | 0.34.0 | Latest stable | +| pydantic | 2.10.4 | Latest stable | +| python-multipart | >=0.0.18 | Required by FastAPI | + +**Node.js:** >= 20.0.0 (required for Vite 6) +**Python:** >= 3.11 (recommended for FastAPI) diff --git a/docs/specs/frontend/37-0-project-setup.md b/docs/specs/frontend/37-0-project-setup.md new file mode 100644 index 0000000000000000000000000000000000000000..6b80a58050b9a30fef911d441821c22b31907b5a --- /dev/null +++ b/docs/specs/frontend/37-0-project-setup.md @@ -0,0 +1,307 @@ +# Spec 37.0: Frontend Project Setup + +**Status**: READY FOR IMPLEMENTATION +**Phase**: 0 of 5 +**Depends On**: Spec 36 (Stack Definition) +**Goal**: Scaffold Vite + React + TypeScript project with testing infrastructure + +--- + +## Deliverables + +By the end of this phase, you will have: + +1. Working Vite dev server (`npm run dev`) +2. TypeScript compilation passing (`npx tsc --noEmit`) +3. Vitest running with a smoke test (`npm test`) +4. Tailwind CSS working +5. MSW configured for API mocking + +--- + +## Step 1: Create Vite Project + +```bash +cd /Users/ray/Desktop/CLARITY-DIGITAL-TWIN/stroke-deepisles-demo +npm create vite@latest frontend -- --template react-ts +cd frontend +``` + +--- + +## Step 2: Install Dependencies + +```bash +# Core dependencies +npm install @niivue/niivue@0.65.0 + +# Dev dependencies - Testing +npm install -D vitest@2.1.8 @vitest/coverage-v8@2.1.8 @vitest/ui@2.1.8 +npm install -D @testing-library/react@16.3.0 @testing-library/jest-dom@6.6.3 @testing-library/user-event@14.5.2 +npm install -D jsdom@25.0.1 msw@2.7.0 + +# Dev dependencies - Styling +npm install -D tailwindcss@4.1.7 @tailwindcss/vite@4.1.7 +``` + +--- + +## Step 3: Configure package.json Scripts + +Replace scripts section: + +```json +{ + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "test": "vitest", + "test:ui": "vitest --ui", + "test:coverage": "vitest run --coverage" + } +} +``` + +--- + +## Step 4: Configure Vite + Vitest + +Replace `vite.config.ts`: + +```typescript +/// +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' + +export default defineConfig({ + plugins: [react(), tailwindcss()], + build: { + outDir: 'dist', + }, + test: { + globals: true, + environment: 'jsdom', + setupFiles: ['./src/test/setup.ts'], + include: ['src/**/*.{test,spec}.{ts,tsx}'], + exclude: ['node_modules', 'e2e'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + include: ['src/**/*.{ts,tsx}'], + exclude: [ + 'src/**/*.test.{ts,tsx}', + 'src/test/**', + 'src/mocks/**', + 'src/main.tsx', + ], + }, + }, +}) +``` + +--- + +## Step 5: Configure TypeScript + +Replace `tsconfig.json`: + +```json +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "types": ["vitest/globals", "@testing-library/jest-dom"] + }, + "include": ["src"] +} +``` + +--- + +## Step 6: Configure Tailwind CSS + +Replace `src/index.css`: + +```css +@import "tailwindcss"; +``` + +--- + +## Step 7: Create Test Setup + +Create `src/test/setup.ts`: + +```typescript +import '@testing-library/jest-dom/vitest' +import { cleanup } from '@testing-library/react' +import { afterEach, beforeAll, afterAll } from 'vitest' +import { server } from '../mocks/server' + +// Establish API mocking before all tests +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) + +// Clean up after each test +afterEach(() => { + cleanup() + server.resetHandlers() +}) + +// Clean up after all tests +afterAll(() => server.close()) + +// Mock ResizeObserver (needed for some UI components) +global.ResizeObserver = class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} +``` + +--- + +## Step 8: Create MSW Mocks + +Create `src/mocks/handlers.ts`: + +```typescript +import { http, HttpResponse } from 'msw' + +const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:7860' + +export const handlers = [ + // GET /api/cases - List available cases + http.get(`${API_BASE}/api/cases`, () => { + return HttpResponse.json({ + cases: ['sub-stroke0001', 'sub-stroke0002', 'sub-stroke0003'], + }) + }), + + // POST /api/segment - Run segmentation + http.post(`${API_BASE}/api/segment`, async ({ request }) => { + const body = (await request.json()) as { case_id: string; fast_mode?: boolean } + return HttpResponse.json({ + caseId: body.case_id, + diceScore: 0.847, + volumeMl: 15.32, + elapsedSeconds: body.fast_mode === false ? 45.0 : 12.5, + dwiUrl: `${API_BASE}/files/dwi.nii.gz`, + predictionUrl: `${API_BASE}/files/prediction.nii.gz`, + }) + }), +] +``` + +Create `src/mocks/server.ts`: + +```typescript +import { setupServer } from 'msw/node' +import { handlers } from './handlers' + +export const server = setupServer(...handlers) +``` + +--- + +## Step 9: Create Smoke Test + +Create `src/App.test.tsx`: + +```typescript +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import App from './App' + +describe('App', () => { + it('renders without crashing', () => { + render() + // This will pass with default Vite template + expect(document.body).toBeInTheDocument() + }) +}) +``` + +--- + +## Step 10: Create Environment File + +Create `.env`: + +```env +VITE_API_URL=http://localhost:7860 +``` + +Create `.env.example`: + +```env +VITE_API_URL=http://localhost:7860 +``` + +--- + +## Verification Checklist + +Run these commands to verify setup: + +```bash +# 1. TypeScript compiles +npx tsc --noEmit +# Expected: No errors + +# 2. Dev server starts +npm run dev +# Expected: Server at http://localhost:5173 + +# 3. Tests pass +npm test +# Expected: 1 test passing + +# 4. Build works +npm run build +# Expected: dist/ folder created +``` + +--- + +## File Structure After This Phase + +``` +frontend/ +├── src/ +│ ├── mocks/ +│ │ ├── handlers.ts +│ │ └── server.ts +│ ├── test/ +│ │ └── setup.ts +│ ├── App.tsx +│ ├── App.test.tsx +│ ├── main.tsx +│ └── index.css +├── .env +├── .env.example +├── index.html +├── package.json +├── tsconfig.json +└── vite.config.ts +``` + +--- + +## Next Phase + +Once verification passes, proceed to **Spec 37.1: Foundation Components** diff --git a/docs/specs/frontend/37-1-foundation-components.md b/docs/specs/frontend/37-1-foundation-components.md new file mode 100644 index 0000000000000000000000000000000000000000..0ace4b4f99e9fd055105e0bcc0f788c291a0b63d --- /dev/null +++ b/docs/specs/frontend/37-1-foundation-components.md @@ -0,0 +1,331 @@ +# Spec 37.1: Foundation Components + +**Status**: READY FOR IMPLEMENTATION +**Phase**: 1 of 5 +**Depends On**: Spec 37.0 (Project Setup) +**Goal**: TDD implementation of Layout and MetricsPanel components + +--- + +## Deliverables + +By the end of this phase, you will have: + +1. `Layout` component with header and main content area +2. `MetricsPanel` component displaying segmentation results +3. 100% test coverage for both components +4. Visual verification in browser + +--- + +## Component 1: Layout + +### Test First + +Create `src/components/__tests__/Layout.test.tsx`: + +```typescript +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import { Layout } from '../Layout' + +describe('Layout', () => { + it('renders header with title', () => { + render(Content) + + expect( + screen.getByRole('heading', { name: /stroke lesion segmentation/i }) + ).toBeInTheDocument() + }) + + it('renders subtitle', () => { + render(Content) + + expect(screen.getByText(/deepisles segmentation/i)).toBeInTheDocument() + }) + + it('renders children in main area', () => { + render( + +
Test Child
+
+ ) + + expect(screen.getByTestId('child')).toBeInTheDocument() + }) + + it('has accessible landmark structure', () => { + render(Content) + + expect(screen.getByRole('banner')).toBeInTheDocument() + expect(screen.getByRole('main')).toBeInTheDocument() + }) + + it('applies dark theme styling', () => { + render(Content) + + const container = screen.getByRole('banner').parentElement + expect(container).toHaveClass('bg-gray-950') + }) +}) +``` + +### Implementation + +Create `src/components/Layout.tsx`: + +```typescript +import { ReactNode } from 'react' + +interface LayoutProps { + children: ReactNode +} + +export function Layout({ children }: LayoutProps) { + return ( +
+
+
+

Stroke Lesion Segmentation

+

+ DeepISLES segmentation on ISLES24 dataset +

+
+
+
{children}
+
+ ) +} +``` + +### Verify + +```bash +npm test -- Layout +# Expected: 5 tests passing +``` + +--- + +## Component 2: MetricsPanel + +### Test First + +Create `src/components/__tests__/MetricsPanel.test.tsx`: + +```typescript +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import { MetricsPanel } from '../MetricsPanel' + +describe('MetricsPanel', () => { + const defaultMetrics = { + caseId: 'sub-stroke0001', + diceScore: 0.847, + volumeMl: 15.32, + elapsedSeconds: 12.5, + } + + it('renders results heading', () => { + render() + + expect( + screen.getByRole('heading', { name: /results/i }) + ).toBeInTheDocument() + }) + + it('displays case ID', () => { + render() + + expect(screen.getByText('sub-stroke0001')).toBeInTheDocument() + }) + + it('displays dice score with 3 decimal places', () => { + render() + + expect(screen.getByText('0.847')).toBeInTheDocument() + }) + + it('displays volume in mL with 2 decimal places', () => { + render() + + expect(screen.getByText('15.32 mL')).toBeInTheDocument() + }) + + it('displays elapsed time with 1 decimal place', () => { + render() + + expect(screen.getByText('12.5s')).toBeInTheDocument() + }) + + it('hides dice score row when null', () => { + render( + + ) + + expect(screen.queryByText(/dice score/i)).not.toBeInTheDocument() + }) + + it('hides volume row when null', () => { + render( + + ) + + expect(screen.queryByText(/volume/i)).not.toBeInTheDocument() + }) + + it('applies card styling', () => { + render() + + const panel = screen.getByRole('heading', { name: /results/i }).parentElement + expect(panel).toHaveClass('bg-gray-800', 'rounded-lg') + }) +}) +``` + +### Implementation + +Create `src/components/MetricsPanel.tsx`: + +```typescript +interface Metrics { + caseId: string + diceScore: number | null + volumeMl: number | null + elapsedSeconds: number +} + +interface MetricsPanelProps { + metrics: Metrics +} + +export function MetricsPanel({ metrics }: MetricsPanelProps) { + return ( +
+

Results

+ +
+
+ Case: + {metrics.caseId} +
+ + {metrics.diceScore !== null && ( +
+ Dice Score: + + {metrics.diceScore.toFixed(3)} + +
+ )} + + {metrics.volumeMl !== null && ( +
+ Volume: + + {metrics.volumeMl.toFixed(2)} mL + +
+ )} + +
+ Time: + + {metrics.elapsedSeconds.toFixed(1)}s + +
+
+
+ ) +} +``` + +### Verify + +```bash +npm test -- MetricsPanel +# Expected: 8 tests passing +``` + +--- + +## Create Index Export + +Create `src/components/index.ts`: + +```typescript +export { Layout } from './Layout' +export { MetricsPanel } from './MetricsPanel' +``` + +--- + +## Visual Verification + +Update `src/App.tsx` to see components: + +```typescript +import { Layout } from './components/Layout' +import { MetricsPanel } from './components/MetricsPanel' + +const mockMetrics = { + caseId: 'sub-stroke0001', + diceScore: 0.847, + volumeMl: 15.32, + elapsedSeconds: 12.5, +} + +function App() { + return ( + +
+ +
+
+ ) +} + +export default App +``` + +Run dev server and verify visually: + +```bash +npm run dev +# Open http://localhost:5173 +``` + +--- + +## Verification Checklist + +- [ ] `npm test` - All 13+ tests pass +- [ ] `npm run dev` - Components render correctly +- [ ] Header shows "Stroke Lesion Segmentation" +- [ ] MetricsPanel shows all metrics with correct formatting +- [ ] Dark theme applies correctly + +--- + +## File Structure After This Phase + +``` +frontend/src/ +├── components/ +│ ├── __tests__/ +│ │ ├── Layout.test.tsx +│ │ └── MetricsPanel.test.tsx +│ ├── Layout.tsx +│ ├── MetricsPanel.tsx +│ └── index.ts +├── mocks/ +├── test/ +├── App.tsx (updated) +└── ... +``` + +--- + +## Next Phase + +Once verification passes, proceed to **Spec 37.2: API Layer** diff --git a/docs/specs/frontend/37-2-api-layer.md b/docs/specs/frontend/37-2-api-layer.md new file mode 100644 index 0000000000000000000000000000000000000000..df47a2aa124bd0c738b62eab13d6fb07b36aad21 --- /dev/null +++ b/docs/specs/frontend/37-2-api-layer.md @@ -0,0 +1,523 @@ +# Spec 37.2: API Layer + +**Status**: READY FOR IMPLEMENTATION +**Phase**: 2 of 5 +**Depends On**: Spec 37.1 (Foundation Components) +**Goal**: TDD implementation of API client and useSegmentation hook + +--- + +## Deliverables + +By the end of this phase, you will have: + +1. Type definitions for API responses +2. `apiClient` with `getCases()` and `runSegmentation()` methods +3. `useSegmentation` React hook for state management +4. MSW handlers for all API endpoints +5. Error handling tests + +--- + +## Step 1: Type Definitions + +Create `src/types/index.ts`: + +```typescript +export interface Metrics { + caseId: string + diceScore: number | null + volumeMl: number | null + elapsedSeconds: number +} + +export interface SegmentationResult { + dwiUrl: string + predictionUrl: string + metrics: Metrics +} + +export interface CasesResponse { + cases: string[] +} + +export interface SegmentResponse { + caseId: string + diceScore: number | null + volumeMl: number | null + elapsedSeconds: number + dwiUrl: string + predictionUrl: string +} +``` + +--- + +## Step 2: Test Fixtures + +Create `src/test/fixtures.ts`: + +```typescript +import type { SegmentationResult, CasesResponse } from '../types' + +export const mockCases: string[] = [ + 'sub-stroke0001', + 'sub-stroke0002', + 'sub-stroke0003', +] + +export const mockCasesResponse: CasesResponse = { + cases: mockCases, +} + +export const mockSegmentationResult: SegmentationResult = { + dwiUrl: 'http://localhost:7860/files/dwi.nii.gz', + predictionUrl: 'http://localhost:7860/files/prediction.nii.gz', + metrics: { + caseId: 'sub-stroke0001', + diceScore: 0.847, + volumeMl: 15.32, + elapsedSeconds: 12.5, + }, +} +``` + +--- + +## Step 3: Enhanced MSW Handlers + +Update `src/mocks/handlers.ts`: + +```typescript +import { http, HttpResponse, delay } from 'msw' + +const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:7860' + +export const handlers = [ + http.get(`${API_BASE}/api/cases`, async () => { + await delay(100) + return HttpResponse.json({ + cases: ['sub-stroke0001', 'sub-stroke0002', 'sub-stroke0003'], + }) + }), + + http.post(`${API_BASE}/api/segment`, async ({ request }) => { + const body = (await request.json()) as { case_id: string; fast_mode?: boolean } + await delay(200) + return HttpResponse.json({ + caseId: body.case_id, + diceScore: 0.847, + volumeMl: 15.32, + // Reflect fast_mode in response - slower when fast_mode=false + elapsedSeconds: body.fast_mode === false ? 45.0 : 12.5, + dwiUrl: `${API_BASE}/files/dwi.nii.gz`, + predictionUrl: `${API_BASE}/files/prediction.nii.gz`, + }) + }), +] + +// Error handlers for testing error states +export const errorHandlers = { + casesServerError: http.get(`${API_BASE}/api/cases`, () => { + return HttpResponse.json( + { detail: 'Internal server error' }, + { status: 500 } + ) + }), + + casesNetworkError: http.get(`${API_BASE}/api/cases`, () => { + return HttpResponse.error() + }), + + segmentServerError: http.post(`${API_BASE}/api/segment`, () => { + return HttpResponse.json( + { detail: 'Segmentation failed: out of memory' }, + { status: 500 } + ) + }), + + segmentTimeout: http.post(`${API_BASE}/api/segment`, async () => { + await delay(30000) + return HttpResponse.json({ detail: 'Timeout' }, { status: 504 }) + }), +} +``` + +--- + +## Step 4: API Client + +### Test First + +Create `src/api/__tests__/client.test.ts`: + +```typescript +import { describe, it, expect } from 'vitest' +import { server } from '../../mocks/server' +import { errorHandlers } from '../../mocks/handlers' +import { apiClient } from '../client' + +describe('apiClient', () => { + describe('getCases', () => { + it('returns list of case IDs', async () => { + const result = await apiClient.getCases() + + expect(result.cases).toHaveLength(3) + expect(result.cases).toContain('sub-stroke0001') + }) + + it('throws ApiError on server error', async () => { + server.use(errorHandlers.casesServerError) + + await expect(apiClient.getCases()).rejects.toThrow(/failed to fetch cases/i) + }) + + it('throws ApiError on network error', async () => { + server.use(errorHandlers.casesNetworkError) + + await expect(apiClient.getCases()).rejects.toThrow() + }) + }) + + describe('runSegmentation', () => { + it('returns segmentation result', async () => { + const result = await apiClient.runSegmentation('sub-stroke0001') + + expect(result.caseId).toBe('sub-stroke0001') + expect(result.diceScore).toBe(0.847) + expect(result.volumeMl).toBe(15.32) + expect(result.dwiUrl).toContain('dwi.nii.gz') + expect(result.predictionUrl).toContain('prediction.nii.gz') + }) + + it('sends fast_mode parameter', async () => { + const result = await apiClient.runSegmentation('sub-stroke0001', false) + + expect(result).toBeDefined() + }) + + it('defaults fast_mode to true', async () => { + const result = await apiClient.runSegmentation('sub-stroke0001') + + expect(result).toBeDefined() + }) + + it('throws ApiError on server error', async () => { + server.use(errorHandlers.segmentServerError) + + await expect( + apiClient.runSegmentation('sub-stroke0001') + ).rejects.toThrow(/segmentation failed/i) + }) + }) +}) +``` + +### Implementation + +Create `src/api/client.ts`: + +```typescript +import type { CasesResponse, SegmentResponse } from '../types' + +const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:7860' + +export class ApiError extends Error { + constructor( + message: string, + public status: number, + public detail?: string + ) { + super(message) + this.name = 'ApiError' + } +} + +class ApiClient { + private baseUrl: string + + constructor(baseUrl: string) { + this.baseUrl = baseUrl + } + + async getCases(): Promise { + const response = await fetch(`${this.baseUrl}/api/cases`) + + if (!response.ok) { + const error = await response.json().catch(() => ({})) + throw new ApiError( + `Failed to fetch cases: ${response.statusText}`, + response.status, + error.detail + ) + } + + return response.json() + } + + async runSegmentation( + caseId: string, + fastMode: boolean = true + ): Promise { + const response = await fetch(`${this.baseUrl}/api/segment`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + case_id: caseId, + fast_mode: fastMode, + }), + }) + + if (!response.ok) { + const error = await response.json().catch(() => ({})) + throw new ApiError( + `Segmentation failed: ${error.detail || response.statusText}`, + response.status, + error.detail + ) + } + + return response.json() + } +} + +export const apiClient = new ApiClient(API_BASE) +``` + +### Verify + +```bash +npm test -- client +# Expected: 7 tests passing +``` + +--- + +## Step 5: useSegmentation Hook + +### Test First + +Create `src/hooks/__tests__/useSegmentation.test.tsx`: + +```typescript +import { describe, it, expect } from 'vitest' +import { renderHook, waitFor, act } from '@testing-library/react' +import { server } from '../../mocks/server' +import { errorHandlers } from '../../mocks/handlers' +import { useSegmentation } from '../useSegmentation' + +describe('useSegmentation', () => { + it('starts with null result and not loading', () => { + const { result } = renderHook(() => useSegmentation()) + + expect(result.current.result).toBeNull() + expect(result.current.isLoading).toBe(false) + expect(result.current.error).toBeNull() + }) + + it('sets loading state during segmentation', async () => { + const { result } = renderHook(() => useSegmentation()) + + act(() => { + result.current.runSegmentation('sub-stroke0001') + }) + + expect(result.current.isLoading).toBe(true) + + await waitFor(() => { + expect(result.current.isLoading).toBe(false) + }) + }) + + it('returns result on success', async () => { + const { result } = renderHook(() => useSegmentation()) + + await act(async () => { + await result.current.runSegmentation('sub-stroke0001') + }) + + expect(result.current.result).not.toBeNull() + expect(result.current.result?.metrics.caseId).toBe('sub-stroke0001') + expect(result.current.result?.metrics.diceScore).toBe(0.847) + expect(result.current.result?.dwiUrl).toContain('dwi.nii.gz') + }) + + it('sets error on failure', async () => { + server.use(errorHandlers.segmentServerError) + + const { result } = renderHook(() => useSegmentation()) + + await act(async () => { + await result.current.runSegmentation('sub-stroke0001') + }) + + expect(result.current.error).toMatch(/segmentation failed/i) + expect(result.current.result).toBeNull() + }) + + it('clears previous error on new request', async () => { + server.use(errorHandlers.segmentServerError) + const { result } = renderHook(() => useSegmentation()) + + // First request fails + await act(async () => { + await result.current.runSegmentation('sub-stroke0001') + }) + expect(result.current.error).not.toBeNull() + + // Reset to success handler + server.resetHandlers() + + // Second request succeeds + await act(async () => { + await result.current.runSegmentation('sub-stroke0001') + }) + + expect(result.current.error).toBeNull() + expect(result.current.result).not.toBeNull() + }) + + it('clears previous result on new request', async () => { + const { result } = renderHook(() => useSegmentation()) + + // First request + await act(async () => { + await result.current.runSegmentation('sub-stroke0001') + }) + expect(result.current.result).not.toBeNull() + + // Start second request - result should clear while loading + act(() => { + result.current.runSegmentation('sub-stroke0002') + }) + + // While loading, previous result is still available + // (or you could clear it - depends on UX preference) + expect(result.current.isLoading).toBe(true) + }) +}) +``` + +### Implementation + +Create `src/hooks/useSegmentation.ts`: + +```typescript +import { useState, useCallback } from 'react' +import { apiClient } from '../api/client' +import type { SegmentationResult } from '../types' + +export function useSegmentation() { + const [result, setResult] = useState(null) + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + + const runSegmentation = useCallback(async (caseId: string, fastMode = true) => { + setIsLoading(true) + setError(null) + + try { + const data = await apiClient.runSegmentation(caseId, fastMode) + + setResult({ + dwiUrl: data.dwiUrl, + predictionUrl: data.predictionUrl, + metrics: { + caseId: data.caseId, + diceScore: data.diceScore, + volumeMl: data.volumeMl, + elapsedSeconds: data.elapsedSeconds, + }, + }) + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error' + setError(message) + setResult(null) + } finally { + setIsLoading(false) + } + }, []) + + return { result, isLoading, error, runSegmentation } +} +``` + +### Verify + +```bash +npm test -- useSegmentation +# Expected: 6 tests passing +``` + +--- + +## Step 6: Create Index Export + +Create `src/hooks/index.ts`: + +```typescript +export { useSegmentation } from './useSegmentation' +``` + +Create `src/api/index.ts`: + +```typescript +export { apiClient, ApiError } from './client' +``` + +--- + +## Verification Checklist + +```bash +# Run all tests +npm test + +# Expected output: +# - client.test.ts: 7 tests passing +# - useSegmentation.test.tsx: 6 tests passing +# Total: ~25+ tests passing +``` + +- [ ] API client handles success responses +- [ ] API client handles error responses +- [ ] Hook manages loading state correctly +- [ ] Hook manages error state correctly +- [ ] Hook transforms API response to SegmentationResult + +--- + +## File Structure After This Phase + +``` +frontend/src/ +├── api/ +│ ├── __tests__/ +│ │ └── client.test.ts +│ ├── client.ts +│ └── index.ts +├── hooks/ +│ ├── __tests__/ +│ │ └── useSegmentation.test.tsx +│ ├── useSegmentation.ts +│ └── index.ts +├── types/ +│ └── index.ts +├── test/ +│ ├── setup.ts +│ └── fixtures.ts +├── mocks/ +│ ├── handlers.ts (updated) +│ └── server.ts +├── components/ +│ └── ... +└── ... +``` + +--- + +## Next Phase + +Once verification passes, proceed to **Spec 37.3: Interactive Components** diff --git a/docs/specs/frontend/37-3-interactive-components.md b/docs/specs/frontend/37-3-interactive-components.md new file mode 100644 index 0000000000000000000000000000000000000000..bdad5162de92f53efd4b65e4013e26de75b7065e --- /dev/null +++ b/docs/specs/frontend/37-3-interactive-components.md @@ -0,0 +1,681 @@ +# Spec 37.3: Interactive Components + +**Status**: READY FOR IMPLEMENTATION +**Phase**: 3 of 5 +**Depends On**: Spec 37.2 (API Layer) +**Goal**: TDD implementation of CaseSelector and NiiVueViewer components + +--- + +## Deliverables + +By the end of this phase, you will have: + +1. `CaseSelector` dropdown that fetches and displays cases +2. `NiiVueViewer` component for 3D medical image viewing +3. Loading and error states for both components +4. WebGL mocking for NiiVue tests + +--- + +## Component 1: CaseSelector + +### Test First + +Create `src/components/__tests__/CaseSelector.test.tsx`: + +```typescript +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { server } from '../../mocks/server' +import { errorHandlers } from '../../mocks/handlers' +import { CaseSelector } from '../CaseSelector' + +describe('CaseSelector', () => { + const mockOnSelectCase = vi.fn() + + beforeEach(() => { + mockOnSelectCase.mockClear() + }) + + it('shows loading state initially', () => { + render( + + ) + + expect(screen.getByText(/loading/i)).toBeInTheDocument() + }) + + it('renders select after loading', async () => { + render( + + ) + + await waitFor(() => { + expect(screen.getByRole('combobox')).toBeInTheDocument() + }) + }) + + it('displays all cases as options', async () => { + render( + + ) + + await waitFor(() => { + expect(screen.getByRole('combobox')).toBeInTheDocument() + }) + + expect(screen.getByRole('option', { name: /sub-stroke0001/i })).toBeInTheDocument() + expect(screen.getByRole('option', { name: /sub-stroke0002/i })).toBeInTheDocument() + expect(screen.getByRole('option', { name: /sub-stroke0003/i })).toBeInTheDocument() + }) + + it('has placeholder option', async () => { + render( + + ) + + await waitFor(() => { + expect(screen.getByRole('combobox')).toBeInTheDocument() + }) + + expect(screen.getByRole('option', { name: /choose a case/i })).toBeInTheDocument() + }) + + it('calls onSelectCase when case selected', async () => { + const user = userEvent.setup() + + render( + + ) + + await waitFor(() => { + expect(screen.getByRole('combobox')).toBeInTheDocument() + }) + + await user.selectOptions(screen.getByRole('combobox'), 'sub-stroke0001') + + expect(mockOnSelectCase).toHaveBeenCalledWith('sub-stroke0001') + }) + + it('shows selected case value', async () => { + render( + + ) + + await waitFor(() => { + expect(screen.getByRole('combobox')).toHaveValue('sub-stroke0002') + }) + }) + + it('shows error state on API failure', async () => { + server.use(errorHandlers.casesServerError) + + render( + + ) + + await waitFor(() => { + expect(screen.getByText(/failed to load/i)).toBeInTheDocument() + }) + }) + + it('applies correct styling', async () => { + render( + + ) + + await waitFor(() => { + expect(screen.getByRole('combobox')).toBeInTheDocument() + }) + + const container = screen.getByRole('combobox').closest('div') + expect(container).toHaveClass('bg-gray-800') + }) +}) +``` + +### Implementation + +Create `src/components/CaseSelector.tsx`: + +```typescript +import { useEffect, useState } from 'react' +import { apiClient } from '../api/client' + +interface CaseSelectorProps { + selectedCase: string | null + onSelectCase: (caseId: string) => void +} + +export function CaseSelector({ selectedCase, onSelectCase }: CaseSelectorProps) { + const [cases, setCases] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + const fetchCases = async () => { + try { + const data = await apiClient.getCases() + setCases(data.cases) + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error' + setError(`Failed to load cases: ${message}`) + } finally { + setIsLoading(false) + } + } + + fetchCases() + }, []) + + if (isLoading) { + return ( +
+

Loading cases...

+
+ ) + } + + if (error) { + return ( +
+

{error}

+
+ ) + } + + return ( +
+ + +
+ ) +} +``` + +### Verify + +```bash +npm test -- CaseSelector +# Expected: 9 tests passing +``` + +--- + +## Component 2: NiiVueViewer + +### WebGL Mock Setup + +Update `src/test/setup.ts` to add WebGL mocking: + +```typescript +import '@testing-library/jest-dom/vitest' +import { cleanup } from '@testing-library/react' +import { afterEach, beforeAll, afterAll, vi } from 'vitest' +import { server } from '../mocks/server' + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) + +afterEach(() => { + cleanup() + server.resetHandlers() +}) + +afterAll(() => server.close()) + +// Mock ResizeObserver +global.ResizeObserver = class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} + +// Mock WebGL2 context for NiiVue +// NiiVue requires specific extensions for float textures (overlays) +// See: https://github.com/niivue/niivue#browser-requirements +const mockExtensions: Record = { + // Required for float textures (overlay rendering) + EXT_color_buffer_float: {}, + OES_texture_float_linear: {}, + // Required for WebGL context management + WEBGL_lose_context: { + loseContext: vi.fn(), + restoreContext: vi.fn(), + }, + // Optional but commonly requested + EXT_texture_filter_anisotropic: { + TEXTURE_MAX_ANISOTROPY_EXT: 0x84fe, + MAX_TEXTURE_MAX_ANISOTROPY_EXT: 0x84ff, + }, + WEBGL_debug_renderer_info: { + UNMASKED_VENDOR_WEBGL: 0x9245, + UNMASKED_RENDERER_WEBGL: 0x9246, + }, +} + +const mockWebGL2Context = { + canvas: null as HTMLCanvasElement | null, + drawingBufferWidth: 640, + drawingBufferHeight: 480, + createShader: vi.fn(() => ({})), + shaderSource: vi.fn(), + compileShader: vi.fn(), + getShaderParameter: vi.fn(() => true), + getShaderInfoLog: vi.fn(() => ''), + createProgram: vi.fn(() => ({})), + attachShader: vi.fn(), + linkProgram: vi.fn(), + getProgramParameter: vi.fn(() => true), + getProgramInfoLog: vi.fn(() => ''), + useProgram: vi.fn(), + getAttribLocation: vi.fn(() => 0), + getUniformLocation: vi.fn(() => ({})), + createBuffer: vi.fn(() => ({})), + bindBuffer: vi.fn(), + bufferData: vi.fn(), + enableVertexAttribArray: vi.fn(), + vertexAttribPointer: vi.fn(), + createTexture: vi.fn(() => ({})), + bindTexture: vi.fn(), + texParameteri: vi.fn(), + texParameterf: vi.fn(), + texImage2D: vi.fn(), + texImage3D: vi.fn(), + texStorage2D: vi.fn(), + texStorage3D: vi.fn(), + texSubImage2D: vi.fn(), + texSubImage3D: vi.fn(), + activeTexture: vi.fn(), + generateMipmap: vi.fn(), + uniform1i: vi.fn(), + uniform1f: vi.fn(), + uniform2f: vi.fn(), + uniform2fv: vi.fn(), + uniform3f: vi.fn(), + uniform3fv: vi.fn(), + uniform4f: vi.fn(), + uniform4fv: vi.fn(), + uniformMatrix4fv: vi.fn(), + viewport: vi.fn(), + scissor: vi.fn(), + clear: vi.fn(), + clearColor: vi.fn(), + clearDepth: vi.fn(), + enable: vi.fn(), + disable: vi.fn(), + blendFunc: vi.fn(), + blendFuncSeparate: vi.fn(), + depthFunc: vi.fn(), + depthMask: vi.fn(), + cullFace: vi.fn(), + drawArrays: vi.fn(), + drawElements: vi.fn(), + // CRITICAL: Return stub extensions for NiiVue float texture support + getExtension: vi.fn((name: string) => mockExtensions[name] || null), + getParameter: vi.fn((pname: number) => { + // Return reasonable defaults for common parameter queries + if (pname === 0x0d33) return 16384 // MAX_TEXTURE_SIZE + if (pname === 0x8073) return 2048 // MAX_3D_TEXTURE_SIZE + if (pname === 0x851c) return 16 // MAX_TEXTURE_IMAGE_UNITS + return 0 + }), + getSupportedExtensions: vi.fn(() => Object.keys(mockExtensions)), + pixelStorei: vi.fn(), + readPixels: vi.fn(), + createFramebuffer: vi.fn(() => ({})), + bindFramebuffer: vi.fn(), + framebufferTexture2D: vi.fn(), + checkFramebufferStatus: vi.fn(() => 36053), // FRAMEBUFFER_COMPLETE + createRenderbuffer: vi.fn(() => ({})), + bindRenderbuffer: vi.fn(), + renderbufferStorage: vi.fn(), + framebufferRenderbuffer: vi.fn(), + deleteTexture: vi.fn(), + deleteBuffer: vi.fn(), + deleteProgram: vi.fn(), + deleteShader: vi.fn(), + deleteFramebuffer: vi.fn(), + deleteRenderbuffer: vi.fn(), + createVertexArray: vi.fn(() => ({})), + bindVertexArray: vi.fn(), + deleteVertexArray: vi.fn(), + flush: vi.fn(), + finish: vi.fn(), + isContextLost: vi.fn(() => false), +} + +HTMLCanvasElement.prototype.getContext = function ( + contextType: string +): RenderingContext | null { + if (contextType === 'webgl2' || contextType === 'webgl') { + return { + ...mockWebGL2Context, + canvas: this, + } as unknown as WebGL2RenderingContext + } + return null +} +``` + +### Test First + +Create `src/components/__tests__/NiiVueViewer.test.tsx`: + +```typescript +import { describe, it, expect, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import { NiiVueViewer } from '../NiiVueViewer' + +// Mock the NiiVue module since it requires actual WebGL +vi.mock('@niivue/niivue', () => ({ + Niivue: vi.fn().mockImplementation(() => ({ + attachToCanvas: vi.fn(), + loadVolumes: vi.fn().mockResolvedValue(undefined), + setSliceType: vi.fn(), + cleanup: vi.fn(), // NiiVue's cleanup() releases event listeners/observers + gl: { + getExtension: vi.fn(() => ({ loseContext: vi.fn() })), + }, + opts: {}, + })), +})) + +describe('NiiVueViewer', () => { + const defaultProps = { + backgroundUrl: 'http://localhost:7860/files/dwi.nii.gz', + } + + it('renders canvas element', () => { + render() + + expect(document.querySelector('canvas')).toBeInTheDocument() + }) + + it('renders container with correct styling', () => { + render() + + const container = document.querySelector('canvas')?.parentElement + expect(container).toHaveClass('bg-gray-900') + }) + + it('renders help text for controls', () => { + render() + + expect(screen.getByText(/scroll/i)).toBeInTheDocument() + expect(screen.getByText(/drag/i)).toBeInTheDocument() + }) + + it('initializes NiiVue with background volume', async () => { + const { Niivue } = await import('@niivue/niivue') + + render() + + expect(Niivue).toHaveBeenCalled() + }) + + it('loads overlay when provided', async () => { + const { Niivue } = await import('@niivue/niivue') + const mockInstance = { + attachToCanvas: vi.fn(), + loadVolumes: vi.fn().mockResolvedValue(undefined), + cleanup: vi.fn(), + gl: { getExtension: vi.fn(() => ({ loseContext: vi.fn() })) }, + opts: {}, + } + ;(Niivue as unknown as ReturnType).mockImplementation( + () => mockInstance + ) + + render( + + ) + + // Wait for useEffect to run + await waitFor(() => { + expect(mockInstance.loadVolumes).toHaveBeenCalled() + }) + + const loadVolumesCall = mockInstance.loadVolumes.mock.calls[0][0] + expect(loadVolumesCall).toHaveLength(2) + expect(loadVolumesCall[1].url).toContain('prediction.nii.gz') + }) + + it('sets canvas dimensions', () => { + render() + + const canvas = document.querySelector('canvas') + expect(canvas).toHaveClass('w-full', 'h-[500px]') + }) +}) +``` + +### Implementation + +Create `src/components/NiiVueViewer.tsx`: + +```typescript +import { useRef, useEffect } from 'react' +import { Niivue } from '@niivue/niivue' + +interface NiiVueViewerProps { + backgroundUrl: string + overlayUrl?: string +} + +export function NiiVueViewer({ backgroundUrl, overlayUrl }: NiiVueViewerProps) { + const canvasRef = useRef(null) + const nvRef = useRef(null) + + useEffect(() => { + if (!canvasRef.current) return + + // Only instantiate NiiVue once; reuse for volume reloads + let nv = nvRef.current + if (!nv) { + nv = new Niivue({ + backColor: [0.05, 0.05, 0.05, 1], + show3Dcrosshair: true, + crosshairColor: [1, 0, 0, 0.5], + }) + nv.attachToCanvas(canvasRef.current) + nvRef.current = nv + } + + // Build volumes array - always reload when URLs change + const volumes: Array<{ url: string; colormap: string; opacity: number }> = [ + { url: backgroundUrl, colormap: 'gray', opacity: 1 }, + ] + + if (overlayUrl) { + volumes.push({ + url: overlayUrl, + colormap: 'red', + opacity: 0.5, + }) + } + + // Load volumes (async but we don't await - just fire off) + void nv.loadVolumes(volumes) + + // Cleanup on unmount - CRITICAL: Release WebGL context + // Browsers limit WebGL contexts (~16 in Chrome). Without cleanup, + // navigating between results will exhaust contexts and break the viewer. + return () => { + if (nvRef.current) { + // Capture gl BEFORE cleanup (cleanup may null internal state) + const gl = nvRef.current.gl + try { + // NiiVue's cleanup() releases event listeners and observers + // See: https://niivue.github.io/niivue/devdocs/classes/Niivue.html#cleanup + nvRef.current.cleanup() + // Force WebGL context loss to free GPU memory immediately + if (gl) { + const ext = gl.getExtension('WEBGL_lose_context') + ext?.loseContext() + } + } catch { + // Ignore cleanup errors + } + nvRef.current = null + } + } + }, [backgroundUrl, overlayUrl]) + + return ( +
+ +
+ Scroll: Navigate slices + Drag: Adjust contrast + Right-click: Pan +
+
+ ) +} +``` + +### Verify + +```bash +npm test -- NiiVueViewer +# Expected: 6 tests passing +``` + +--- + +## Update Component Index + +Update `src/components/index.ts`: + +```typescript +export { Layout } from './Layout' +export { MetricsPanel } from './MetricsPanel' +export { CaseSelector } from './CaseSelector' +export { NiiVueViewer } from './NiiVueViewer' +``` + +--- + +## Visual Verification + +Update `src/App.tsx` to preview all components: + +```typescript +import { useState } from 'react' +import { Layout } from './components/Layout' +import { CaseSelector } from './components/CaseSelector' +import { MetricsPanel } from './components/MetricsPanel' +import { NiiVueViewer } from './components/NiiVueViewer' + +const mockMetrics = { + caseId: 'sub-stroke0001', + diceScore: 0.847, + volumeMl: 15.32, + elapsedSeconds: 12.5, +} + +// Demo NIfTI file from NiiVue examples +const DEMO_NIFTI = 'https://niivue.github.io/niivue-demo-images/mni152.nii.gz' + +function App() { + const [selectedCase, setSelectedCase] = useState(null) + + return ( + +
+
+ + +
+
+ +
+
+
+ ) +} + +export default App +``` + +```bash +npm run dev +# Open http://localhost:5173 +# Verify: +# - CaseSelector loads and shows cases +# - NiiVue viewer renders 3D brain +# - MetricsPanel displays correctly +``` + +--- + +## Verification Checklist + +```bash +npm test +# Expected: ~35+ tests passing +``` + +- [ ] CaseSelector shows loading state +- [ ] CaseSelector fetches and displays cases +- [ ] CaseSelector calls onSelectCase on selection +- [ ] CaseSelector shows error state on API failure +- [ ] NiiVueViewer renders canvas +- [ ] NiiVueViewer initializes NiiVue instance +- [ ] NiiVueViewer loads overlay when provided +- [ ] Visual: All components render correctly in browser + +--- + +## File Structure After This Phase + +```text +frontend/src/ +├── components/ +│ ├── __tests__/ +│ │ ├── Layout.test.tsx +│ │ ├── MetricsPanel.test.tsx +│ │ ├── CaseSelector.test.tsx +│ │ └── NiiVueViewer.test.tsx +│ ├── Layout.tsx +│ ├── MetricsPanel.tsx +│ ├── CaseSelector.tsx +│ ├── NiiVueViewer.tsx +│ └── index.ts +├── api/ +├── hooks/ +├── types/ +├── test/ +│ └── setup.ts (updated with WebGL mocks) +├── mocks/ +└── App.tsx (updated) +``` + +--- + +## Next Phase + +Once verification passes, proceed to **Spec 37.4: App Integration** diff --git a/docs/specs/frontend/37-4-app-integration.md b/docs/specs/frontend/37-4-app-integration.md new file mode 100644 index 0000000000000000000000000000000000000000..1656eab633c699e9206f69dcb82c3538285e08aa --- /dev/null +++ b/docs/specs/frontend/37-4-app-integration.md @@ -0,0 +1,461 @@ +# Spec 37.4: App Integration + +**Status**: READY FOR IMPLEMENTATION +**Phase**: 4 of 5 +**Depends On**: Spec 37.3 (Interactive Components) +**Goal**: Wire all components together into a working application + +--- + +## Deliverables + +By the end of this phase, you will have: + +1. Complete `App.tsx` with full user flow +2. Integration tests for the complete workflow +3. Error handling for all states +4. Working end-to-end flow (with mocked API) + +--- + +## Step 1: App Integration Tests + +Create `src/App.test.tsx`: + +```typescript +import { describe, it, expect, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { server } from './mocks/server' +import { errorHandlers } from './mocks/handlers' +import App from './App' + +describe('App Integration', () => { + describe('Initial Render', () => { + it('renders main heading', () => { + render() + + expect( + screen.getByRole('heading', { name: /stroke lesion segmentation/i }) + ).toBeInTheDocument() + }) + + it('renders case selector', async () => { + render() + + await waitFor(() => { + expect(screen.getByRole('combobox')).toBeInTheDocument() + }) + }) + + it('renders run button', () => { + render() + + expect( + screen.getByRole('button', { name: /run segmentation/i }) + ).toBeInTheDocument() + }) + + it('shows placeholder viewer message', () => { + render() + + expect( + screen.getByText(/select a case and run segmentation/i) + ).toBeInTheDocument() + }) + }) + + describe('Run Button State', () => { + it('disables run button when no case selected', async () => { + render() + + await waitFor(() => { + expect(screen.getByRole('combobox')).toBeInTheDocument() + }) + + expect( + screen.getByRole('button', { name: /run segmentation/i }) + ).toBeDisabled() + }) + + it('enables run button when case selected', async () => { + const user = userEvent.setup() + render() + + await waitFor(() => { + expect(screen.getByRole('combobox')).toBeInTheDocument() + }) + + await user.selectOptions(screen.getByRole('combobox'), 'sub-stroke0001') + + expect( + screen.getByRole('button', { name: /run segmentation/i }) + ).toBeEnabled() + }) + }) + + describe('Segmentation Flow', () => { + it('shows processing state when running', async () => { + const user = userEvent.setup() + render() + + await waitFor(() => { + expect(screen.getByRole('combobox')).toBeInTheDocument() + }) + + await user.selectOptions(screen.getByRole('combobox'), 'sub-stroke0001') + await user.click(screen.getByRole('button', { name: /run segmentation/i })) + + expect(screen.getByText(/processing/i)).toBeInTheDocument() + }) + + it('displays metrics after successful segmentation', async () => { + const user = userEvent.setup() + render() + + await waitFor(() => { + expect(screen.getByRole('combobox')).toBeInTheDocument() + }) + + await user.selectOptions(screen.getByRole('combobox'), 'sub-stroke0001') + await user.click(screen.getByRole('button', { name: /run segmentation/i })) + + await waitFor(() => { + expect(screen.getByText('0.847')).toBeInTheDocument() + }) + + expect(screen.getByText('15.32 mL')).toBeInTheDocument() + expect(screen.getByText(/12\.5s/)).toBeInTheDocument() + }) + + it('displays viewer after successful segmentation', async () => { + const user = userEvent.setup() + render() + + await waitFor(() => { + expect(screen.getByRole('combobox')).toBeInTheDocument() + }) + + await user.selectOptions(screen.getByRole('combobox'), 'sub-stroke0001') + await user.click(screen.getByRole('button', { name: /run segmentation/i })) + + await waitFor(() => { + expect(document.querySelector('canvas')).toBeInTheDocument() + }) + }) + + it('hides placeholder after successful segmentation', async () => { + const user = userEvent.setup() + render() + + await waitFor(() => { + expect(screen.getByRole('combobox')).toBeInTheDocument() + }) + + await user.selectOptions(screen.getByRole('combobox'), 'sub-stroke0001') + await user.click(screen.getByRole('button', { name: /run segmentation/i })) + + await waitFor(() => { + expect(screen.getByText('0.847')).toBeInTheDocument() + }) + + expect( + screen.queryByText(/select a case and run segmentation/i) + ).not.toBeInTheDocument() + }) + }) + + describe('Error Handling', () => { + it('shows error when segmentation fails', async () => { + server.use(errorHandlers.segmentServerError) + const user = userEvent.setup() + + render() + + await waitFor(() => { + expect(screen.getByRole('combobox')).toBeInTheDocument() + }) + + await user.selectOptions(screen.getByRole('combobox'), 'sub-stroke0001') + await user.click(screen.getByRole('button', { name: /run segmentation/i })) + + await waitFor(() => { + expect(screen.getByRole('alert')).toBeInTheDocument() + }) + + expect(screen.getByText(/segmentation failed/i)).toBeInTheDocument() + }) + + it('allows retry after error', async () => { + server.use(errorHandlers.segmentServerError) + const user = userEvent.setup() + + render() + + await waitFor(() => { + expect(screen.getByRole('combobox')).toBeInTheDocument() + }) + + await user.selectOptions(screen.getByRole('combobox'), 'sub-stroke0001') + await user.click(screen.getByRole('button', { name: /run segmentation/i })) + + await waitFor(() => { + expect(screen.getByRole('alert')).toBeInTheDocument() + }) + + // Reset to success handler + server.resetHandlers() + + // Retry + await user.click(screen.getByRole('button', { name: /run segmentation/i })) + + await waitFor(() => { + expect(screen.getByText('0.847')).toBeInTheDocument() + }) + + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) + }) + + describe('Multiple Runs', () => { + it('allows running segmentation on different cases', async () => { + const user = userEvent.setup() + render() + + await waitFor(() => { + expect(screen.getByRole('combobox')).toBeInTheDocument() + }) + + // First case + await user.selectOptions(screen.getByRole('combobox'), 'sub-stroke0001') + await user.click(screen.getByRole('button', { name: /run segmentation/i })) + + await waitFor(() => { + expect(screen.getByText('sub-stroke0001')).toBeInTheDocument() + }) + + // Second case + await user.selectOptions(screen.getByRole('combobox'), 'sub-stroke0002') + await user.click(screen.getByRole('button', { name: /run segmentation/i })) + + await waitFor(() => { + expect(screen.getByText('sub-stroke0002')).toBeInTheDocument() + }) + }) + }) +}) +``` + +--- + +## Step 2: App Implementation + +Replace `src/App.tsx`: + +```typescript +import { useState } from 'react' +import { Layout } from './components/Layout' +import { CaseSelector } from './components/CaseSelector' +import { NiiVueViewer } from './components/NiiVueViewer' +import { MetricsPanel } from './components/MetricsPanel' +import { useSegmentation } from './hooks/useSegmentation' + +export default function App() { + const [selectedCase, setSelectedCase] = useState(null) + const { result, isLoading, error, runSegmentation } = useSegmentation() + + const handleRunSegmentation = async () => { + if (selectedCase) { + await runSegmentation(selectedCase) + } + } + + return ( + +
+ {/* Left Panel: Controls */} +
+ + + + + {error && ( +
+ {error} +
+ )} + + {result && } +
+ + {/* Right Panel: Viewer */} +
+ {result ? ( + + ) : ( +
+

+ Select a case and run segmentation to view results +

+
+ )} +
+
+
+ ) +} +``` + +--- + +## Step 3: Run Tests + +```bash +npm test +# Expected: ~45+ tests passing +``` + +--- + +## Step 4: Visual Verification + +```bash +npm run dev +# Open http://localhost:5173 +``` + +**Manual Test Checklist:** + +1. [ ] Page loads with header +2. [ ] Case selector shows "Loading cases..." +3. [ ] Case selector populates with 3 cases +4. [ ] Run button is disabled initially +5. [ ] Selecting a case enables run button +6. [ ] Clicking run shows "Processing..." +7. [ ] After completion, metrics panel appears +8. [ ] After completion, viewer shows (with demo image) +9. [ ] Selecting different case and running updates results + +--- + +## Step 5: Test Custom Render Utility (Optional Enhancement) + +Create `src/test/test-utils.tsx`: + +```typescript +import type { ReactElement, ReactNode } from 'react' +import { render, RenderOptions } from '@testing-library/react' +import userEvent from '@testing-library/user-event' + +// Wrapper for any providers (Router, Theme, etc.) +function AllTheProviders({ children }: { children: ReactNode }) { + return <>{children} +} + +function customRender( + ui: ReactElement, + options?: Omit +) { + return { + user: userEvent.setup(), + ...render(ui, { wrapper: AllTheProviders, ...options }), + } +} + +// Re-export everything +export * from '@testing-library/react' +export { customRender as render } +``` + +Update tests to use custom render (optional): + +```typescript +import { render, screen, waitFor } from '../test/test-utils' +// Now `render` returns `{ user, ...result }` +``` + +--- + +## Verification Checklist + +```bash +# All tests pass +npm test +# Expected: ~45+ tests passing + +# Build succeeds +npm run build +# Expected: dist/ folder created + +# No TypeScript errors +npx tsc --noEmit +# Expected: No errors +``` + +- [ ] All integration tests pass +- [ ] Full flow works in browser +- [ ] Error states display correctly +- [ ] Loading states display correctly +- [ ] Results update on new runs + +--- + +## File Structure After This Phase + +``` +frontend/src/ +├── components/ +│ ├── __tests__/ +│ │ ├── Layout.test.tsx +│ │ ├── MetricsPanel.test.tsx +│ │ ├── CaseSelector.test.tsx +│ │ └── NiiVueViewer.test.tsx +│ ├── Layout.tsx +│ ├── MetricsPanel.tsx +│ ├── CaseSelector.tsx +│ ├── NiiVueViewer.tsx +│ └── index.ts +├── api/ +│ ├── __tests__/ +│ │ └── client.test.ts +│ ├── client.ts +│ └── index.ts +├── hooks/ +│ ├── __tests__/ +│ │ └── useSegmentation.test.tsx +│ ├── useSegmentation.ts +│ └── index.ts +├── types/ +│ └── index.ts +├── test/ +│ ├── setup.ts +│ ├── fixtures.ts +│ └── test-utils.tsx +├── mocks/ +│ ├── handlers.ts +│ └── server.ts +├── App.tsx +├── App.test.tsx +├── main.tsx +└── index.css +``` + +--- + +## Next Phase + +Once verification passes, proceed to **Spec 37.5: E2E Tests & CI/CD** diff --git a/docs/specs/frontend/37-5-e2e-and-ci.md b/docs/specs/frontend/37-5-e2e-and-ci.md new file mode 100644 index 0000000000000000000000000000000000000000..71583f522c054ac618ceb9dec4aeff2ca0d371fd --- /dev/null +++ b/docs/specs/frontend/37-5-e2e-and-ci.md @@ -0,0 +1,607 @@ +# Spec 37.5: E2E Tests & CI/CD + +**Status**: READY FOR IMPLEMENTATION +**Phase**: 5 of 5 +**Depends On**: Spec 37.4 (App Integration) +**Goal**: End-to-end tests with Playwright and GitHub Actions CI pipeline + +--- + +## Deliverables + +By the end of this phase, you will have: + +1. Playwright E2E tests for critical user flows +2. Page Object Models for maintainable tests +3. GitHub Actions workflow for CI +4. Coverage reporting integration + +--- + +## Step 1: Install Playwright + +```bash +cd frontend +npm install -D @playwright/test@1.49.1 +npx playwright install +``` + +--- + +## Step 2: Playwright Configuration + +Create `playwright.config.ts`: + +```typescript +import { defineConfig, devices } from '@playwright/test' + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: [ + ['html', { open: 'never' }], + ['list'], + ...(process.env.CI ? [['github' as const]] : []), + ], + use: { + baseURL: 'http://localhost:5173', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + // Uncomment for cross-browser testing: + // { + // name: 'firefox', + // use: { ...devices['Desktop Firefox'] }, + // }, + // { + // name: 'webkit', + // use: { ...devices['Desktop Safari'] }, + // }, + ], + webServer: { + command: 'npm run dev', + url: 'http://localhost:5173', + reuseExistingServer: !process.env.CI, + timeout: 120000, + }, +}) +``` + +--- + +## Step 3: Update package.json Scripts + +Add to `package.json` scripts: + +```json +{ + "scripts": { + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "test:e2e:headed": "playwright test --headed", + "test:e2e:debug": "playwright test --debug" + } +} +``` + +--- + +## Step 4: Page Object Model + +Create `e2e/pages/HomePage.ts`: + +```typescript +import { type Page, type Locator, expect } from '@playwright/test' + +export class HomePage { + readonly page: Page + readonly heading: Locator + readonly caseSelector: Locator + readonly runButton: Locator + readonly processingText: Locator + readonly metricsPanel: Locator + readonly diceScore: Locator + readonly viewer: Locator + readonly placeholderText: Locator + readonly errorAlert: Locator + + constructor(page: Page) { + this.page = page + this.heading = page.getByRole('heading', { + name: /stroke lesion segmentation/i, + }) + this.caseSelector = page.getByRole('combobox') + this.runButton = page.getByRole('button', { name: /run segmentation/i }) + this.processingText = page.getByText(/processing/i) + this.metricsPanel = page.getByRole('heading', { name: /results/i }) + this.diceScore = page.getByText(/0\.\d{3}/) + this.viewer = page.locator('canvas') + this.placeholderText = page.getByText(/select a case and run segmentation/i) + this.errorAlert = page.getByRole('alert') + } + + async goto() { + await this.page.goto('/') + await expect(this.heading).toBeVisible() + } + + async waitForCasesToLoad() { + await expect(this.caseSelector).toBeEnabled({ timeout: 10000 }) + } + + async selectCase(caseId: string) { + await this.caseSelector.selectOption(caseId) + } + + async runSegmentation() { + await this.runButton.click() + } + + async waitForResults() { + await expect(this.metricsPanel).toBeVisible({ timeout: 30000 }) + } + + async expectViewerVisible() { + await expect(this.viewer).toBeVisible() + } + + async expectPlaceholderVisible() { + await expect(this.placeholderText).toBeVisible() + } + + async expectErrorVisible() { + await expect(this.errorAlert).toBeVisible() + } +} +``` + +--- + +## Step 5: Global API Mocking Fixture + +**CRITICAL:** E2E tests run against `npm run dev` which has no backend. +Without API mocking, tests will hang or fail on API calls. + +Create `e2e/fixtures.ts` - Global mock for all API calls: + +```typescript +import { test as base, expect } from '@playwright/test' + +// API response mocks matching MSW handlers +const MOCK_CASES = ['sub-stroke0001', 'sub-stroke0002', 'sub-stroke0003'] +const MOCK_SEGMENT_RESPONSE = { + caseId: 'sub-stroke0001', + diceScore: 0.847, + volumeMl: 15.32, + elapsedSeconds: 12.5, + // Use a real public NIfTI for visual testing (NiiVue demo image) + dwiUrl: 'https://niivue.github.io/niivue-demo-images/mni152.nii.gz', + predictionUrl: 'https://niivue.github.io/niivue-demo-images/mni152.nii.gz', +} + +// Extend base test to include API mocking +export const test = base.extend({ + // Auto-mock API routes for every test + page: async ({ page }, use) => { + // Mock GET /api/cases + await page.route('**/api/cases', (route) => { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ cases: MOCK_CASES }), + }) + }) + + // Mock POST /api/segment - return different caseId based on request + await page.route('**/api/segment', async (route) => { + const request = route.request() + const body = JSON.parse(request.postData() || '{}') + + // Simulate network delay + await new Promise((r) => setTimeout(r, 200)) + + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + ...MOCK_SEGMENT_RESPONSE, + caseId: body.case_id || 'sub-stroke0001', + }), + }) + }) + + await use(page) + }, +}) + +export { expect } +``` + +--- + +## Step 6: E2E Tests + +Create `e2e/home.spec.ts`: + +```typescript +import { test, expect } from './fixtures' +import { HomePage } from './pages/HomePage' + +test.describe('Home Page', () => { + test('displays main heading', async ({ page }) => { + const homePage = new HomePage(page) + await homePage.goto() + + await expect(homePage.heading).toBeVisible() + }) + + test('loads case selector with options', async ({ page }) => { + const homePage = new HomePage(page) + await homePage.goto() + await homePage.waitForCasesToLoad() + + // Verify selector has options + const options = await homePage.caseSelector.locator('option').count() + expect(options).toBeGreaterThan(1) // placeholder + cases + }) + + test('shows placeholder viewer initially', async ({ page }) => { + const homePage = new HomePage(page) + await homePage.goto() + + await homePage.expectPlaceholderVisible() + }) + + test('run button disabled without case selected', async ({ page }) => { + const homePage = new HomePage(page) + await homePage.goto() + await homePage.waitForCasesToLoad() + + await expect(homePage.runButton).toBeDisabled() + }) +}) +``` + +Create `e2e/segmentation-flow.spec.ts`: + +```typescript +import { test, expect } from './fixtures' +import { HomePage } from './pages/HomePage' + +test.describe('Segmentation Flow', () => { + test('complete segmentation workflow', async ({ page }) => { + const homePage = new HomePage(page) + await homePage.goto() + await homePage.waitForCasesToLoad() + + // Select a case + await homePage.selectCase('sub-stroke0001') + await expect(homePage.runButton).toBeEnabled() + + // Run segmentation + await homePage.runSegmentation() + + // Verify processing state + await expect(homePage.processingText).toBeVisible() + + // Wait for results + await homePage.waitForResults() + + // Verify results displayed + await expect(homePage.diceScore).toBeVisible() + await homePage.expectViewerVisible() + + // Placeholder should be gone + await expect(homePage.placeholderText).not.toBeVisible() + }) + + test('can run multiple segmentations', async ({ page }) => { + const homePage = new HomePage(page) + await homePage.goto() + await homePage.waitForCasesToLoad() + + // First run + await homePage.selectCase('sub-stroke0001') + await homePage.runSegmentation() + await homePage.waitForResults() + + // Second run with different case + await homePage.selectCase('sub-stroke0002') + await homePage.runSegmentation() + await homePage.waitForResults() + + // Results should still be visible + await expect(homePage.metricsPanel).toBeVisible() + }) +}) +``` + +Create `e2e/error-handling.spec.ts`: + +```typescript +import { test as base, expect } from '@playwright/test' +import { HomePage } from './pages/HomePage' + +// Error tests need to override the default mocks, so use base test +const test = base + +test.describe('Error Handling', () => { + test('shows error when API fails', async ({ page }) => { + // Mock cases API (needed for page to load) + await page.route('**/api/cases', (route) => { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ cases: ['sub-stroke0001'] }), + }) + }) + + // Mock segment API to return error + await page.route('**/api/segment', (route) => { + route.fulfill({ + status: 500, + contentType: 'application/json', + body: JSON.stringify({ detail: 'Segmentation failed' }), + }) + }) + + const homePage = new HomePage(page) + await homePage.goto() + await homePage.waitForCasesToLoad() + + await homePage.selectCase('sub-stroke0001') + await homePage.runSegmentation() + + await homePage.expectErrorVisible() + await expect(homePage.errorAlert).toContainText(/failed/i) + }) + + test('shows error when cases fail to load', async ({ page }) => { + // Mock cases API to return error + await page.route('**/api/cases', (route) => { + route.fulfill({ + status: 500, + contentType: 'application/json', + body: JSON.stringify({ detail: 'Server error' }), + }) + }) + + const homePage = new HomePage(page) + await homePage.goto() + + // Case selector should show error state + await expect(page.getByText(/failed to load/i)).toBeVisible() + }) +}) +``` + +--- + +## Step 7: GitHub Actions CI Workflow + +Create `.github/workflows/frontend-ci.yml`: + +```yaml +name: Frontend CI + +on: + push: + branches: [main] + paths: + - 'frontend/**' + - '.github/workflows/frontend-ci.yml' + pull_request: + paths: + - 'frontend/**' + +defaults: + run: + working-directory: frontend + +jobs: + typecheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: frontend/package-lock.json + + - run: npm ci + - run: npx tsc --noEmit + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: frontend/package-lock.json + + - run: npm ci + - run: npm run test:coverage + + - uses: codecov/codecov-action@v4 + with: + files: frontend/coverage/coverage-final.json + flags: frontend + fail_ci_if_error: false + + e2e: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: frontend/package-lock.json + + - run: npm ci + - run: npx playwright install --with-deps chromium + + - run: npm run test:e2e + + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: playwright-report + path: frontend/playwright-report/ + retention-days: 7 + + build: + runs-on: ubuntu-latest + needs: [typecheck, test] + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: frontend/package-lock.json + + - run: npm ci + - run: npm run build + + - uses: actions/upload-artifact@v4 + with: + name: frontend-dist + path: frontend/dist/ + retention-days: 7 +``` + +--- + +## Step 8: Add Coverage Thresholds + +Update `vite.config.ts` coverage section: + +```typescript +coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + include: ['src/**/*.{ts,tsx}'], + exclude: [ + 'src/**/*.test.{ts,tsx}', + 'src/test/**', + 'src/mocks/**', + 'src/main.tsx', + 'src/vite-env.d.ts', + ], + thresholds: { + statements: 80, + branches: 75, + functions: 80, + lines: 80, + }, +}, +``` + +--- + +## Step 9: Run All Tests + +```bash +# Unit & Integration tests +npm test +# Expected: ~45+ tests passing + +# E2E tests +npm run test:e2e +# Expected: 7 tests passing + +# Coverage report +npm run test:coverage +# Expected: >80% coverage +``` + +--- + +## Verification Checklist + +- [ ] `npm test` - All unit/integration tests pass +- [ ] `npm run test:coverage` - Coverage meets thresholds +- [ ] `npm run test:e2e` - All E2E tests pass +- [ ] `npm run build` - Production build succeeds +- [ ] CI workflow runs successfully (push to branch) + +--- + +## File Structure After This Phase + +``` +frontend/ +├── e2e/ +│ ├── pages/ +│ │ └── HomePage.ts +│ ├── fixtures.ts # <-- NEW: Global API mocking +│ ├── home.spec.ts +│ ├── segmentation-flow.spec.ts +│ └── error-handling.spec.ts +├── src/ +│ ├── components/ +│ ├── api/ +│ ├── hooks/ +│ ├── types/ +│ ├── test/ +│ ├── mocks/ +│ ├── App.tsx +│ ├── App.test.tsx +│ └── ... +├── .github/ +│ └── workflows/ +│ └── frontend-ci.yml +├── playwright.config.ts +├── vite.config.ts +├── package.json +└── ... +``` + +--- + +## Summary: Complete Testing Stack + +| Layer | Tool | Test Count | Purpose | +|-------|------|------------|---------| +| Unit | Vitest + RTL | ~35 | Component isolation | +| Integration | Vitest + MSW | ~15 | API + hooks | +| E2E | Playwright | ~7 | Full user flows | +| **Total** | | **~57** | | + +--- + +## Next Steps After All Phases Complete + +1. **Deploy Frontend**: Push to HuggingFace Static Space +2. **Connect to Backend**: Update `VITE_API_URL` to real backend +3. **Test Against Real API**: Run E2E tests with real backend +4. **Monitor**: Set up error tracking (optional) + +--- + +## Congratulations! + +You now have a fully tested React frontend with: + +- Type-safe TypeScript code +- Comprehensive unit tests +- API mocking with MSW +- End-to-end browser tests +- Automated CI/CD pipeline +- 80%+ code coverage diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..d077743e55617c131b8fa475c1d0b16e68a2411c --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1 @@ +VITE_API_URL=http://localhost:7860 diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..63417667c4fee0254192d334c21209eb2674917d --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,29 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Test output +coverage +playwright-report +test-results diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000000000000000000000000000000000000..d2e77611fd3d959fee0487c41bd27b318be32b04 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,73 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + + // Remove tseslint.configs.recommended and replace with this + tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + tseslint.configs.stylisticTypeChecked, + + // Other configs... + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x' +import reactDom from 'eslint-plugin-react-dom' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + // Enable lint rules for React + reactX.configs['recommended-typescript'], + // Enable lint rules for React DOM + reactDom.configs.recommended, + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` diff --git a/frontend/e2e/error-handling.spec.ts b/frontend/e2e/error-handling.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..4ecfff3d8dff7becde3075756884ef59bb6d8c36 --- /dev/null +++ b/frontend/e2e/error-handling.spec.ts @@ -0,0 +1,54 @@ +import { test as base, expect } from '@playwright/test' +import { HomePage } from './pages/HomePage' + +// Error tests need to override the default mocks, so use base test +const test = base + +test.describe('Error Handling', () => { + test('shows error when API fails', async ({ page }) => { + // Mock cases API (needed for page to load) + await page.route('**/api/cases', (route) => { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ cases: ['sub-stroke0001'] }), + }) + }) + + // Mock segment API to return error + await page.route('**/api/segment', (route) => { + route.fulfill({ + status: 500, + contentType: 'application/json', + body: JSON.stringify({ detail: 'Segmentation failed' }), + }) + }) + + const homePage = new HomePage(page) + await homePage.goto() + await homePage.waitForCasesToLoad() + + await homePage.selectCase('sub-stroke0001') + await homePage.runSegmentation() + + await homePage.expectErrorVisible() + await expect(homePage.errorAlert).toContainText(/failed/i) + }) + + test('shows error when cases fail to load', async ({ page }) => { + // Mock cases API to return error + await page.route('**/api/cases', (route) => { + route.fulfill({ + status: 500, + contentType: 'application/json', + body: JSON.stringify({ detail: 'Server error' }), + }) + }) + + const homePage = new HomePage(page) + await homePage.goto() + + // Case selector should show error state + await expect(page.getByText(/failed to load/i)).toBeVisible() + }) +}) diff --git a/frontend/e2e/fixtures.ts b/frontend/e2e/fixtures.ts new file mode 100644 index 0000000000000000000000000000000000000000..a674b850cdb0700b7a7fde915c430b7046f96537 --- /dev/null +++ b/frontend/e2e/fixtures.ts @@ -0,0 +1,50 @@ +import { test as base, expect } from '@playwright/test' + +// API response mocks matching MSW handlers +const MOCK_CASES = ['sub-stroke0001', 'sub-stroke0002', 'sub-stroke0003'] +const MOCK_SEGMENT_RESPONSE = { + caseId: 'sub-stroke0001', + diceScore: 0.847, + volumeMl: 15.32, + elapsedSeconds: 12.5, + // Use real public NIfTI for visual testing (NiiVue demo image) + dwiUrl: 'https://niivue.github.io/niivue-demo-images/mni152.nii.gz', + predictionUrl: 'https://niivue.github.io/niivue-demo-images/mni152.nii.gz', +} + +// Extend base test to include API mocking +export const test = base.extend({ + // Auto-mock API routes for every test + page: async ({ page }, use) => { + // Mock GET /api/cases + await page.route('**/api/cases', (route) => { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ cases: MOCK_CASES }), + }) + }) + + // Mock POST /api/segment - return different caseId based on request + await page.route('**/api/segment', async (route) => { + const request = route.request() + const body = JSON.parse(request.postData() || '{}') as { case_id?: string } + + // Simulate network delay + await new Promise((r) => setTimeout(r, 200)) + + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + ...MOCK_SEGMENT_RESPONSE, + caseId: body.case_id || 'sub-stroke0001', + }), + }) + }) + + await use(page) + }, +}) + +export { expect } diff --git a/frontend/e2e/home.spec.ts b/frontend/e2e/home.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..e3fbdd9c93d1d075f3c2dd96dcd8283db90bd126 --- /dev/null +++ b/frontend/e2e/home.spec.ts @@ -0,0 +1,36 @@ +import { test, expect } from './fixtures' +import { HomePage } from './pages/HomePage' + +test.describe('Home Page', () => { + test('displays main heading', async ({ page }) => { + const homePage = new HomePage(page) + await homePage.goto() + + await expect(homePage.heading).toBeVisible() + }) + + test('loads case selector with options', async ({ page }) => { + const homePage = new HomePage(page) + await homePage.goto() + await homePage.waitForCasesToLoad() + + // Verify selector has options + const options = await homePage.caseSelector.locator('option').count() + expect(options).toBeGreaterThan(1) // placeholder + cases + }) + + test('shows placeholder viewer initially', async ({ page }) => { + const homePage = new HomePage(page) + await homePage.goto() + + await homePage.expectPlaceholderVisible() + }) + + test('run button disabled without case selected', async ({ page }) => { + const homePage = new HomePage(page) + await homePage.goto() + await homePage.waitForCasesToLoad() + + await expect(homePage.runButton).toBeDisabled() + }) +}) diff --git a/frontend/e2e/pages/HomePage.ts b/frontend/e2e/pages/HomePage.ts new file mode 100644 index 0000000000000000000000000000000000000000..c923be83e0528db98900d16b229bef8a0af24a31 --- /dev/null +++ b/frontend/e2e/pages/HomePage.ts @@ -0,0 +1,62 @@ +import { type Page, type Locator, expect } from '@playwright/test' + +export class HomePage { + readonly page: Page + readonly heading: Locator + readonly caseSelector: Locator + readonly runButton: Locator + readonly processingText: Locator + readonly metricsPanel: Locator + readonly diceScore: Locator + readonly viewer: Locator + readonly placeholderText: Locator + readonly errorAlert: Locator + + constructor(page: Page) { + this.page = page + this.heading = page.getByRole('heading', { + name: /stroke lesion segmentation/i, + }) + this.caseSelector = page.getByRole('combobox') + this.runButton = page.getByRole('button', { name: /run segmentation/i }) + this.processingText = page.getByText(/processing/i) + this.metricsPanel = page.getByRole('heading', { name: /results/i }) + this.diceScore = page.getByText(/0\.\d{3}/) + this.viewer = page.locator('canvas') + this.placeholderText = page.getByText(/select a case and run segmentation/i) + this.errorAlert = page.getByRole('alert') + } + + async goto() { + await this.page.goto('/') + await expect(this.heading).toBeVisible() + } + + async waitForCasesToLoad() { + await expect(this.caseSelector).toBeEnabled({ timeout: 10000 }) + } + + async selectCase(caseId: string) { + await this.caseSelector.selectOption(caseId) + } + + async runSegmentation() { + await this.runButton.click() + } + + async waitForResults() { + await expect(this.metricsPanel).toBeVisible({ timeout: 30000 }) + } + + async expectViewerVisible() { + await expect(this.viewer).toBeVisible() + } + + async expectPlaceholderVisible() { + await expect(this.placeholderText).toBeVisible() + } + + async expectErrorVisible() { + await expect(this.errorAlert).toBeVisible() + } +} diff --git a/frontend/e2e/segmentation-flow.spec.ts b/frontend/e2e/segmentation-flow.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..b72dfe79c1216f3ec5f84ab5be0dc02d6906891f --- /dev/null +++ b/frontend/e2e/segmentation-flow.spec.ts @@ -0,0 +1,49 @@ +import { test, expect } from './fixtures' +import { HomePage } from './pages/HomePage' + +test.describe('Segmentation Flow', () => { + test('complete segmentation workflow', async ({ page }) => { + const homePage = new HomePage(page) + await homePage.goto() + await homePage.waitForCasesToLoad() + + // Select a case + await homePage.selectCase('sub-stroke0001') + await expect(homePage.runButton).toBeEnabled() + + // Run segmentation + await homePage.runSegmentation() + + // Verify processing state + await expect(homePage.processingText).toBeVisible() + + // Wait for results + await homePage.waitForResults() + + // Verify results displayed + await expect(homePage.diceScore).toBeVisible() + await homePage.expectViewerVisible() + + // Placeholder should be gone + await expect(homePage.placeholderText).not.toBeVisible() + }) + + test('can run multiple segmentations', async ({ page }) => { + const homePage = new HomePage(page) + await homePage.goto() + await homePage.waitForCasesToLoad() + + // First run + await homePage.selectCase('sub-stroke0001') + await homePage.runSegmentation() + await homePage.waitForResults() + + // Second run with different case + await homePage.selectCase('sub-stroke0002') + await homePage.runSegmentation() + await homePage.waitForResults() + + // Results should still be visible + await expect(homePage.metricsPanel).toBeVisible() + }) +}) diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000000000000000000000000000000000000..bf4d1d51be51e2b5a68925f82cee319a347b2e95 --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,33 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist', 'coverage']), + // Main source files - full React rules + { + files: ['src/**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + }, + // E2E tests - Playwright, not React (disable react-hooks rules) + { + files: ['e2e/**/*.{ts,tsx}'], + extends: [js.configs.recommended, tseslint.configs.recommended], + languageOptions: { + ecmaVersion: 2020, + globals: { ...globals.browser, ...globals.node }, + }, + }, +]) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000000000000000000000000000000000000..525493c831f72f0e943970c6ba12cab8c0269823 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Stroke Lesion Segmentation + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000000000000000000000000000000000000..64ee3db5ce19cadcde5e6aed0028066bedf021e5 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,6301 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "@niivue/niivue": "^0.65.0", + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@playwright/test": "^1.57.0", + "@tailwindcss/vite": "^4.1.17", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.5.2", + "@types/node": "^24.10.1", + "@types/react": "^19.2.5", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "@vitest/coverage-v8": "^4.0.15", + "@vitest/ui": "^4.0.15", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "jsdom": "^25.0.1", + "msw": "^2.7.0", + "tailwindcss": "^4.1.17", + "typescript": "~5.9.3", + "typescript-eslint": "^8.46.4", + "vite": "^7.2.4", + "vitest": "^4.0.15" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", + "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", + "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", + "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", + "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@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/@bundled-es-modules/cookie": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@bundled-es-modules/cookie/-/cookie-2.0.1.tgz", + "integrity": "sha512-8o+5fRPLNbjbdGRRmJj3h6Hh1AQJf2dk3qQ/5ZFb+PXkRNiSoMGGUKlsgLfrxneb72axVJyIYji64E2+nNfYyw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cookie": "^0.7.2" + } + }, + "node_modules/@bundled-es-modules/statuses": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@bundled-es-modules/statuses/-/statuses-1.0.1.tgz", + "integrity": "sha512-yn7BklA5acgcBr+7w064fGV+SGIFySjCKpqjcWgBAIfrAkY+4GQTJJHQMeT3V/sgz23VTEVV8TtOmkvJAhFVfg==", + "dev": true, + "license": "ISC", + "dependencies": { + "statuses": "^2.0.1" + } + }, + "node_modules/@bundled-es-modules/tough-cookie": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@bundled-es-modules/tough-cookie/-/tough-cookie-0.1.6.tgz", + "integrity": "sha512-dvMHbL464C0zI+Yqxbz6kZ5TOEp7GLW+pry/RWndAR8MJQAXZ2rPmIs8tziTZjeIyhSNZgZbCePtfSbdWqStJw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@types/tough-cookie": "^4.0.5", + "tough-cookie": "^4.1.4" + } + }, + "node_modules/@bundled-es-modules/tough-cookie/node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz", + "integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@inquirer/ansi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", + "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/confirm": { + "version": "5.1.21", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz", + "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "10.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz", + "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "cli-width": "^4.1.0", + "mute-stream": "^2.0.0", + "signal-exit": "^4.1.0", + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@inquirer/core/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@inquirer/core/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@inquirer/core/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@inquirer/core/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@inquirer/figures": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", + "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/type": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz", + "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@lukeed/csprng": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.1.0.tgz", + "integrity": "sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@lukeed/uuid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@lukeed/uuid/-/uuid-2.0.1.tgz", + "integrity": "sha512-qC72D4+CDdjGqJvkFMMEAtancHUQ7/d/tAiHf64z8MopFDmcrtbcJuerDtFceuAfQJ2pDSfCKCtbqoGBNnwg0w==", + "license": "MIT", + "dependencies": { + "@lukeed/csprng": "^1.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@mswjs/interceptors": { + "version": "0.37.6", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.37.6.tgz", + "integrity": "sha512-wK+5pLK5XFmgtH3aQ2YVvA3HohS3xqV/OxuVOdNx9Wpnz7VE/fnC+e1A7ln6LFYeck7gOJ/dsZV6OLplOtAJ2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/logger": "^0.3.0", + "@open-draft/until": "^2.0.0", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "strict-event-emitter": "^0.5.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@niivue/niivue": { + "version": "0.65.0", + "resolved": "https://registry.npmjs.org/@niivue/niivue/-/niivue-0.65.0.tgz", + "integrity": "sha512-yu4pWI/3pQm4Q/4ga4iBfxPRwV/fmBwFEY35N1r3xVqStZYYm2+3U3Yq7UhbBd36snO1FbQuRGqekincbyh6gQ==", + "license": "BSD-2-Clause", + "dependencies": { + "@lukeed/uuid": "^2.0.1", + "@ungap/structured-clone": "^1.2.0", + "array-equal": "^1.0.2", + "fflate": "^0.8.2", + "gl-matrix": "^3.4.3", + "nifti-reader-js": "^0.8.0", + "zarrita": "^0.5.0" + }, + "optionalDependencies": { + "@rollup/rollup-linux-x64-gnu": "^4.18.1" + } + }, + "node_modules/@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "node_modules/@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@playwright/test": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz", + "integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.57.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.53", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz", + "integrity": "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz", + "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz", + "integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz", + "integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz", + "integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz", + "integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz", + "integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz", + "integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz", + "integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz", + "integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz", + "integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz", + "integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz", + "integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz", + "integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz", + "integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz", + "integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz", + "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz", + "integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz", + "integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz", + "integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz", + "integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz", + "integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz", + "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", + "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.17.tgz", + "integrity": "sha512-csIkHIgLb3JisEFQ0vxr2Y57GUNYh447C8xzwj89U/8fdW8LhProdxvnVH6U8M2Y73QKiTIH+LWbK3V2BBZsAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.6.1", + "lightningcss": "1.30.2", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.17" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.17.tgz", + "integrity": "sha512-F0F7d01fmkQhsTjXezGBLdrl1KresJTcI3DB8EkScCldyKp3Msz4hub4uyYaVnk88BAS1g5DQjjF6F5qczheLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.17", + "@tailwindcss/oxide-darwin-arm64": "4.1.17", + "@tailwindcss/oxide-darwin-x64": "4.1.17", + "@tailwindcss/oxide-freebsd-x64": "4.1.17", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.17", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.17", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.17", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.17", + "@tailwindcss/oxide-linux-x64-musl": "4.1.17", + "@tailwindcss/oxide-wasm32-wasi": "4.1.17", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.17", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.17" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.17.tgz", + "integrity": "sha512-BMqpkJHgOZ5z78qqiGE6ZIRExyaHyuxjgrJ6eBO5+hfrfGkuya0lYfw8fRHG77gdTjWkNWEEm+qeG2cDMxArLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.17.tgz", + "integrity": "sha512-EquyumkQweUBNk1zGEU/wfZo2qkp/nQKRZM8bUYO0J+Lums5+wl2CcG1f9BgAjn/u9pJzdYddHWBiFXJTcxmOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.17.tgz", + "integrity": "sha512-gdhEPLzke2Pog8s12oADwYu0IAw04Y2tlmgVzIN0+046ytcgx8uZmCzEg4VcQh+AHKiS7xaL8kGo/QTiNEGRog==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.17.tgz", + "integrity": "sha512-hxGS81KskMxML9DXsaXT1H0DyA+ZBIbyG/sSAjWNe2EDl7TkPOBI42GBV3u38itzGUOmFfCzk1iAjDXds8Oh0g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.17.tgz", + "integrity": "sha512-k7jWk5E3ldAdw0cNglhjSgv501u7yrMf8oeZ0cElhxU6Y2o7f8yqelOp3fhf7evjIS6ujTI3U8pKUXV2I4iXHQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.17.tgz", + "integrity": "sha512-HVDOm/mxK6+TbARwdW17WrgDYEGzmoYayrCgmLEw7FxTPLcp/glBisuyWkFz/jb7ZfiAXAXUACfyItn+nTgsdQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.17.tgz", + "integrity": "sha512-HvZLfGr42i5anKtIeQzxdkw/wPqIbpeZqe7vd3V9vI3RQxe3xU1fLjss0TjyhxWcBaipk7NYwSrwTwK1hJARMg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.17.tgz", + "integrity": "sha512-M3XZuORCGB7VPOEDH+nzpJ21XPvK5PyjlkSFkFziNHGLc5d6g3di2McAAblmaSUNl8IOmzYwLx9NsE7bplNkwQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.17.tgz", + "integrity": "sha512-k7f+pf9eXLEey4pBlw+8dgfJHY4PZ5qOUFDyNf7SI6lHjQ9Zt7+NcscjpwdCEbYi6FI5c2KDTDWyf2iHcCSyyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.17.tgz", + "integrity": "sha512-cEytGqSSoy7zK4JRWiTCx43FsKP/zGr0CsuMawhH67ONlH+T79VteQeJQRO/X7L0juEUA8ZyuYikcRBf0vsxhg==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.6.0", + "@emnapi/runtime": "^1.6.0", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.0.7", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.17.tgz", + "integrity": "sha512-JU5AHr7gKbZlOGvMdb4722/0aYbU+tN6lv1kONx0JK2cGsh7g148zVWLM0IKR3NeKLv+L90chBVYcJ8uJWbC9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.17.tgz", + "integrity": "sha512-SKWM4waLuqx0IH+FMDUw6R66Hu4OuTALFgnleKbqhgGU30DY20NORZMZUKgLRjQXNN2TLzKvh48QXTig4h4bGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.17.tgz", + "integrity": "sha512-4+9w8ZHOiGnpcGI6z1TVVfWaX/koK7fKeSYF3qlYg2xpBtbteP2ddBxiarL+HVgfSJGeK5RIxRQmKm4rTJJAwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.1.17", + "@tailwindcss/oxide": "4.1.17", + "tailwindcss": "4.1.17" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7" + } + }, + "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/jest-dom": { + "version": "6.6.3", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.6.3.tgz", + "integrity": "sha512-IteBhl4XqYNkM54f4ejhLRJiZNqcSCoXUOG2CPK7qbD322KjQozM4kHQOfkG2oln9b9HTYqs+Sae8vBATubxxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "chalk": "^3.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "lodash": "^4.17.21", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/chalk": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "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.0", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.0.tgz", + "integrity": "sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==", + "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.5.2", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.5.2.tgz", + "integrity": "sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "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", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.10.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.3.tgz", + "integrity": "sha512-gqkrWUsS8hcm0r44yn7/xZeV1ERva/nLgrLxFRUGb7aoNMIJfZJ3AC261zDQuOAKC7MiXai1WCpYc48jAHoShQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", + "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/statuses": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz", + "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.49.0.tgz", + "integrity": "sha512-JXij0vzIaTtCwu6SxTh8qBc66kmf1xs7pI4UOiMDFVct6q86G0Zs7KRcEoJgY3Cav3x5Tq0MF5jwgpgLqgKG3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.49.0", + "@typescript-eslint/type-utils": "8.49.0", + "@typescript-eslint/utils": "8.49.0", + "@typescript-eslint/visitor-keys": "8.49.0", + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.49.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.49.0.tgz", + "integrity": "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.49.0", + "@typescript-eslint/types": "8.49.0", + "@typescript-eslint/typescript-estree": "8.49.0", + "@typescript-eslint/visitor-keys": "8.49.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.49.0.tgz", + "integrity": "sha512-/wJN0/DKkmRUMXjZUXYZpD1NEQzQAAn9QWfGwo+Ai8gnzqH7tvqS7oNVdTjKqOcPyVIdZdyCMoqN66Ia789e7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.49.0", + "@typescript-eslint/types": "^8.49.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.49.0.tgz", + "integrity": "sha512-npgS3zi+/30KSOkXNs0LQXtsg9ekZ8OISAOLGWA/ZOEn0ZH74Ginfl7foziV8DT+D98WfQ5Kopwqb/PZOaIJGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.49.0", + "@typescript-eslint/visitor-keys": "8.49.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.49.0.tgz", + "integrity": "sha512-8prixNi1/6nawsRYxet4YOhnbW+W9FK/bQPxsGB1D3ZrDzbJ5FXw5XmzxZv82X3B+ZccuSxo/X8q9nQ+mFecWA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.49.0.tgz", + "integrity": "sha512-KTExJfQ+svY8I10P4HdxKzWsvtVnsuCifU5MvXrRwoP2KOlNZ9ADNEWWsQTJgMxLzS5VLQKDjkCT/YzgsnqmZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.49.0", + "@typescript-eslint/typescript-estree": "8.49.0", + "@typescript-eslint/utils": "8.49.0", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.49.0.tgz", + "integrity": "sha512-e9k/fneezorUo6WShlQpMxXh8/8wfyc+biu6tnAqA81oWrEic0k21RHzP9uqqpyBBeBKu4T+Bsjy9/b8u7obXQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.49.0.tgz", + "integrity": "sha512-jrLdRuAbPfPIdYNppHJ/D0wN+wwNfJ32YTAm10eJVsFmrVpXQnDWBn8niCSMlWjvml8jsce5E/O+86IQtTbJWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.49.0", + "@typescript-eslint/tsconfig-utils": "8.49.0", + "@typescript-eslint/types": "8.49.0", + "@typescript-eslint/visitor-keys": "8.49.0", + "debug": "^4.3.4", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.49.0.tgz", + "integrity": "sha512-N3W7rJw7Rw+z1tRsHZbK395TWSYvufBXumYtEGzypgMUthlg0/hmCImeA8hgO2d2G4pd7ftpxxul2J8OdtdaFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.49.0", + "@typescript-eslint/types": "8.49.0", + "@typescript-eslint/typescript-estree": "8.49.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.49.0.tgz", + "integrity": "sha512-LlKaciDe3GmZFphXIc79THF/YYBugZ7FS1pO581E/edlVVNbZKDy93evqmrfQ9/Y4uN0vVhX4iuchq26mK/iiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.49.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.2.tgz", + "integrity": "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.5", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.53", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/coverage-v8": { + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.15.tgz", + "integrity": "sha512-FUJ+1RkpTFW7rQITdgTi93qOCWJobWhBirEPCeXh2SW2wsTlFxy51apDz5gzG+ZEYt/THvWeNmhdAoS9DTwpCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.0.15", + "ast-v8-to-istanbul": "^0.3.8", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.1", + "obug": "^2.1.1", + "std-env": "^3.10.0", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.0.15", + "vitest": "4.0.15" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.15.tgz", + "integrity": "sha512-Gfyva9/GxPAWXIWjyGDli9O+waHDC0Q0jaLdFP1qPAUUfo1FEXPXUfUkp3eZA0sSq340vPycSyOlYUeM15Ft1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.15", + "@vitest/utils": "4.0.15", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.15.tgz", + "integrity": "sha512-CZ28GLfOEIFkvCFngN8Sfx5h+Se0zN+h4B7yOsPVCcgtiO7t5jt9xQh2E1UkFep+eb9fjyMfuC5gBypwb07fvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.15", + "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-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.15.tgz", + "integrity": "sha512-SWdqR8vEv83WtZcrfLNqlqeQXlQLh2iilO1Wk1gv4eiHKjEzvgHb2OVc3mIPyhZE6F+CtfYjNlDJwP5MN6Km7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.15.tgz", + "integrity": "sha512-+A+yMY8dGixUhHmNdPUxOh0la6uVzun86vAbuMT3hIDxMrAOmn5ILBHm8ajrqHE0t8R9T1dGnde1A5DTnmi3qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.15", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.15.tgz", + "integrity": "sha512-A7Ob8EdFZJIBjLjeO0DZF4lqR6U7Ydi5/5LIZ0xcI+23lYlsYJAfGn8PrIWTYdZQRNnSRlzhg0zyGu37mVdy5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.15", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.15.tgz", + "integrity": "sha512-+EIjOJmnY6mIfdXtE/bnozKEvTC4Uczg19yeZ2vtCz5Yyb0QQ31QWVQ8hswJ3Ysx/K2EqaNsVanjr//2+P3FHw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/ui": { + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.0.15.tgz", + "integrity": "sha512-sxSyJMaKp45zI0u+lHrPuZM1ZJQ8FaVD35k+UxVrha1yyvQ+TZuUYllUixwvQXlB7ixoDc7skf3lQPopZIvaQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.15", + "fflate": "^0.8.2", + "flatted": "^3.3.3", + "pathe": "^2.0.3", + "sirv": "^3.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "4.0.15" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.15.tgz", + "integrity": "sha512-HXjPW2w5dxhTD0dLwtYHDnelK3j8sR8cWIaLxr22evTyY6q8pRCjZSmhRWVjBaOVXChQd6AwMzi9pucorXCPZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.15", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@zarrita/storage": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@zarrita/storage/-/storage-0.1.3.tgz", + "integrity": "sha512-ZyCMYN3LuCNtKxro9876r/KyHyXV+ie2Bhk1qYsJR4Jp+sAjoVRRNNSJPsJxk64ZgFFezayO5S2hCu88/1Odwg==", + "license": "MIT", + "dependencies": { + "reference-spec-reader": "^0.2.0", + "unzipit": "^1.4.3" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-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", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/array-equal": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-equal/-/array-equal-1.0.2.tgz", + "integrity": "sha512-gUHx76KtnhEgB3HOuFYiCm3FIdEs6ocM2asHvNTkfu/Y09qQVrrVVaOKENmS2KkSaGoxgXNqC+ZVtR/n0MOkSA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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-v8-to-istanbul": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.8.tgz", + "integrity": "sha512-szgSZqUxI5T8mLKvS7WTjF9is+MVbOeLADU73IseOcrqhxr/VAvy6wfoVE39KnKzA7JRhjF5eUagNlHwvZPlKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^9.0.1" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.6", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.6.tgz", + "integrity": "sha512-v9BVVpOTLB59C9E7aSnmIF8h7qRsFpx+A2nugVMTszEOMcfjlZMsXRm4LF23I3Z9AJxc8ANpIvzbzONoX9VJlg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001760", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001760.tgz", + "integrity": "sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.1.tgz", + "integrity": "sha512-p4Z49OGG5W/WBCPSS/dH3jQ73kD6tiMmUM+bckNK6Jr5JHMG3k9bg/BvKR8lKmtVBKmOiuVaV2ws8s9oSbwysg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.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/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.267", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", + "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.18.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", + "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz", + "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.1", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.24.tgz", + "integrity": "sha512-nLHIW7TEq3aLrEYWpVaJ1dRgFR+wLDPN8e8FpYAql/bMV2oBEfC37K0gLEGgv9fy66juNShSMV8OkTqzltcG/w==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/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/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/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/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gl-matrix": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", + "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", + "license": "MIT" + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/graphql": { + "version": "16.12.0", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.12.0.tgz", + "integrity": "sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, + "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-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/headers-polyfill": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-4.0.3.tgz", + "integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "dev": true, + "license": "MIT" + }, + "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/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "25.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", + "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.1.0", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", + "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.30.2", + "lightningcss-darwin-arm64": "1.30.2", + "lightningcss-darwin-x64": "1.30.2", + "lightningcss-freebsd-x64": "1.30.2", + "lightningcss-linux-arm-gnueabihf": "1.30.2", + "lightningcss-linux-arm64-gnu": "1.30.2", + "lightningcss-linux-arm64-musl": "1.30.2", + "lightningcss-linux-x64-gnu": "1.30.2", + "lightningcss-linux-x64-musl": "1.30.2", + "lightningcss-win32-arm64-msvc": "1.30.2", + "lightningcss-win32-x64-msvc": "1.30.2" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", + "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", + "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", + "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", + "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", + "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", + "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", + "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", + "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", + "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", + "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", + "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/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/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.1.tgz", + "integrity": "sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "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": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "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, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/msw": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.7.0.tgz", + "integrity": "sha512-BIodwZ19RWfCbYTxWTUfTXc+sg4OwjCAgxU1ZsgmggX/7S3LdUifsbUPJs61j0rWb19CZRGY5if77duhc0uXzw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@bundled-es-modules/cookie": "^2.0.1", + "@bundled-es-modules/statuses": "^1.0.1", + "@bundled-es-modules/tough-cookie": "^0.1.6", + "@inquirer/confirm": "^5.0.0", + "@mswjs/interceptors": "^0.37.0", + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/until": "^2.1.0", + "@types/cookie": "^0.6.0", + "@types/statuses": "^2.0.4", + "graphql": "^16.8.1", + "headers-polyfill": "^4.0.2", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "path-to-regexp": "^6.3.0", + "picocolors": "^1.1.1", + "strict-event-emitter": "^0.5.1", + "type-fest": "^4.26.1", + "yargs": "^17.7.2" + }, + "bin": { + "msw": "cli/index.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mswjs" + }, + "peerDependencies": { + "typescript": ">= 4.8.x" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/mute-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", + "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/nifti-reader-js": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/nifti-reader-js/-/nifti-reader-js-0.8.0.tgz", + "integrity": "sha512-iO1iIhQDfKniy+l/86HfOPte7So+SxBmBiMSiUB2VXU7z4hSewMTlE3h0fCgfzfXvMUa+ilzLTJ2ZHmtFw6EWw==", + "license": "MIT", + "dependencies": { + "fflate": "^0.8.2" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/numcodecs": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/numcodecs/-/numcodecs-0.3.2.tgz", + "integrity": "sha512-6YSPnmZgg0P87jnNhi3s+FVLOcIn3y+1CTIgUulA3IdASzK9fJM87sUFkpyA+be9GibGRaST2wCgkD+6U+fWKw==", + "license": "MIT", + "dependencies": { + "fflate": "^0.8.0" + } + }, + "node_modules/nwsapi": { + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/outvariant": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", + "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", + "dev": true, + "license": "MIT" + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz", + "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.57.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz", + "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/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-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "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/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.1", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz", + "integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz", + "integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.1" + } + }, + "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/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/reference-spec-reader": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/reference-spec-reader/-/reference-spec-reader-0.2.0.tgz", + "integrity": "sha512-q0mfCi5yZSSHXpCyxjgQeaORq3tvDsxDyzaadA/5+AbAUwRyRuuTh0aRQuE/vAOt/qzzxidJ5iDeu1cLHaNBlQ==", + "license": "MIT" + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rollup": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", + "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.53.3", + "@rollup/rollup-android-arm64": "4.53.3", + "@rollup/rollup-darwin-arm64": "4.53.3", + "@rollup/rollup-darwin-x64": "4.53.3", + "@rollup/rollup-freebsd-arm64": "4.53.3", + "@rollup/rollup-freebsd-x64": "4.53.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", + "@rollup/rollup-linux-arm-musleabihf": "4.53.3", + "@rollup/rollup-linux-arm64-gnu": "4.53.3", + "@rollup/rollup-linux-arm64-musl": "4.53.3", + "@rollup/rollup-linux-loong64-gnu": "4.53.3", + "@rollup/rollup-linux-ppc64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-musl": "4.53.3", + "@rollup/rollup-linux-s390x-gnu": "4.53.3", + "@rollup/rollup-linux-x64-gnu": "4.53.3", + "@rollup/rollup-linux-x64-musl": "4.53.3", + "@rollup/rollup-openharmony-arm64": "4.53.3", + "@rollup/rollup-win32-arm64-msvc": "4.53.3", + "@rollup/rollup-win32-ia32-msvc": "4.53.3", + "@rollup/rollup-win32-x64-gnu": "4.53.3", + "@rollup/rollup-win32-x64-msvc": "4.53.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strict-event-emitter": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", + "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/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/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/tailwindcss": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz", + "integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.49.0.tgz", + "integrity": "sha512-zRSVH1WXD0uXczCXw+nsdjGPUdx4dfrs5VQoHnUWmv1U3oNlAKv4FUNdLDhVUg+gYn+a5hUESqch//Rv5wVhrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.49.0", + "@typescript-eslint/parser": "8.49.0", + "@typescript-eslint/typescript-estree": "8.49.0", + "@typescript-eslint/utils": "8.49.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/unzipit": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/unzipit/-/unzipit-1.4.3.tgz", + "integrity": "sha512-gsq2PdJIWWGhx5kcdWStvNWit9FVdTewm4SEG7gFskWs+XCVaULt9+BwuoBtJiRE8eo3L1IPAOrbByNLtLtIlg==", + "license": "MIT", + "dependencies": { + "uzip-module": "^1.0.2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.2.tgz", + "integrity": "sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, + "node_modules/uzip-module": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/uzip-module/-/uzip-module-1.0.3.tgz", + "integrity": "sha512-AMqwWZaknLM77G+VPYNZLEruMGWGzyigPK3/Whg99B3S6vGHuqsyl5ZrOv1UUF3paGK1U6PM0cnayioaryg/fA==", + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.2.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.7.tgz", + "integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.15.tgz", + "integrity": "sha512-n1RxDp8UJm6N0IbJLQo+yzLZ2sQCDyl1o0LeugbPWf8+8Fttp29GghsQBjYJVmWq3gBFfe9Hs1spR44vovn2wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.15", + "@vitest/mocker": "4.0.15", + "@vitest/pretty-format": "4.0.15", + "@vitest/runner": "4.0.15", + "@vitest/snapshot": "4.0.15", + "@vitest/spy": "4.0.15", + "@vitest/utils": "4.0.15", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.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.0.15", + "@vitest/browser-preview": "4.0.15", + "@vitest/browser-webdriverio": "4.0.15", + "@vitest/ui": "4.0.15", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yoctocolors-cjs": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", + "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zarrita": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/zarrita/-/zarrita-0.5.4.tgz", + "integrity": "sha512-i88iN2+HqIQ+uiCEWLfhjbYNXAJD7IrM4h3lFwFclfqEOOhxp10amRWtqmgN5jbuy3+h0LwdyLVVzk4y9rTLgg==", + "license": "MIT", + "dependencies": { + "@zarrita/storage": "^0.1.3", + "numcodecs": "^0.3.2" + } + }, + "node_modules/zod": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.13.tgz", + "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000000000000000000000000000000000000..f2ef35226ae1986399f094d4ec3e239707eee38e --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,50 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "test": "vitest", + "test:ui": "vitest --ui", + "test:coverage": "vitest run --coverage", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "test:e2e:headed": "playwright test --headed", + "test:e2e:debug": "playwright test --debug" + }, + "dependencies": { + "@niivue/niivue": "^0.65.0", + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@playwright/test": "^1.57.0", + "@tailwindcss/vite": "^4.1.17", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.5.2", + "@types/node": "^24.10.1", + "@types/react": "^19.2.5", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "@vitest/coverage-v8": "^4.0.15", + "@vitest/ui": "^4.0.15", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "jsdom": "^25.0.1", + "msw": "^2.7.0", + "tailwindcss": "^4.1.17", + "typescript": "~5.9.3", + "typescript-eslint": "^8.46.4", + "vite": "^7.2.4", + "vitest": "^4.0.15" + } +} diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000000000000000000000000000000000000..00b7802317596c01ed97a1a27b0f9350ae1c219e --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,31 @@ +import { defineConfig, devices } from '@playwright/test' + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: [ + ['html', { open: 'never' }], + ['list'], + ...(process.env.CI ? [['github' as const]] : []), + ], + use: { + baseURL: 'http://localhost:5173', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + webServer: { + command: 'npm run dev', + url: 'http://localhost:5173', + reuseExistingServer: !process.env.CI, + timeout: 120000, + }, +}) diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg new file mode 100644 index 0000000000000000000000000000000000000000..ee9fadaf9c4a762ac0ec010ca16ce8fa39a09e56 --- /dev/null +++ b/frontend/public/vite.svg @@ -0,0 +1 @@ + diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7115e56cf48dd5941705ea1fe2ed163bb156866b --- /dev/null +++ b/frontend/src/App.test.tsx @@ -0,0 +1,240 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { server } from './mocks/server' +import { errorHandlers } from './mocks/handlers' +import App from './App' + +// Mock NiiVue to avoid WebGL in tests +vi.mock('@niivue/niivue', () => ({ + Niivue: class MockNiivue { + attachToCanvas = vi.fn() + loadVolumes = vi.fn().mockResolvedValue(undefined) + cleanup = vi.fn() + gl = { + getExtension: vi.fn(() => ({ loseContext: vi.fn() })), + } + opts = {} + }, +})) + +describe('App Integration', () => { + describe('Initial Render', () => { + it('renders main heading', () => { + render() + + expect( + screen.getByRole('heading', { name: /stroke lesion segmentation/i }) + ).toBeInTheDocument() + }) + + it('renders case selector', async () => { + render() + + await waitFor(() => { + expect(screen.getByRole('combobox')).toBeInTheDocument() + }) + }) + + it('renders run button', () => { + render() + + expect( + screen.getByRole('button', { name: /run segmentation/i }) + ).toBeInTheDocument() + }) + + it('shows placeholder viewer message', () => { + render() + + expect( + screen.getByText(/select a case and run segmentation/i) + ).toBeInTheDocument() + }) + }) + + describe('Run Button State', () => { + it('disables run button when no case selected', async () => { + render() + + await waitFor(() => { + expect(screen.getByRole('combobox')).toBeInTheDocument() + }) + + expect( + screen.getByRole('button', { name: /run segmentation/i }) + ).toBeDisabled() + }) + + it('enables run button when case selected', async () => { + const user = userEvent.setup() + render() + + await waitFor(() => { + expect(screen.getByRole('combobox')).toBeInTheDocument() + }) + + await user.selectOptions(screen.getByRole('combobox'), 'sub-stroke0001') + + expect( + screen.getByRole('button', { name: /run segmentation/i }) + ).toBeEnabled() + }) + }) + + describe('Segmentation Flow', () => { + it('shows processing state when running', async () => { + const user = userEvent.setup() + render() + + await waitFor(() => { + expect(screen.getByRole('combobox')).toBeInTheDocument() + }) + + await user.selectOptions(screen.getByRole('combobox'), 'sub-stroke0001') + await user.click(screen.getByRole('button', { name: /run segmentation/i })) + + expect(screen.getByText(/processing/i)).toBeInTheDocument() + }) + + it('displays metrics after successful segmentation', async () => { + const user = userEvent.setup() + render() + + await waitFor(() => { + expect(screen.getByRole('combobox')).toBeInTheDocument() + }) + + await user.selectOptions(screen.getByRole('combobox'), 'sub-stroke0001') + await user.click(screen.getByRole('button', { name: /run segmentation/i })) + + await waitFor(() => { + expect(screen.getByText('0.847')).toBeInTheDocument() + }) + + expect(screen.getByText('15.32 mL')).toBeInTheDocument() + expect(screen.getByText(/12\.5s/)).toBeInTheDocument() + }) + + it('displays viewer after successful segmentation', async () => { + const user = userEvent.setup() + render() + + await waitFor(() => { + expect(screen.getByRole('combobox')).toBeInTheDocument() + }) + + await user.selectOptions(screen.getByRole('combobox'), 'sub-stroke0001') + await user.click(screen.getByRole('button', { name: /run segmentation/i })) + + await waitFor(() => { + expect(document.querySelector('canvas')).toBeInTheDocument() + }) + }) + + it('hides placeholder after successful segmentation', async () => { + const user = userEvent.setup() + render() + + await waitFor(() => { + expect(screen.getByRole('combobox')).toBeInTheDocument() + }) + + await user.selectOptions(screen.getByRole('combobox'), 'sub-stroke0001') + await user.click(screen.getByRole('button', { name: /run segmentation/i })) + + await waitFor(() => { + expect(screen.getByText('0.847')).toBeInTheDocument() + }) + + expect( + screen.queryByText(/select a case and run segmentation/i) + ).not.toBeInTheDocument() + }) + }) + + describe('Error Handling', () => { + it('shows error when segmentation fails', async () => { + server.use(errorHandlers.segmentServerError) + const user = userEvent.setup() + + render() + + await waitFor(() => { + expect(screen.getByRole('combobox')).toBeInTheDocument() + }) + + await user.selectOptions(screen.getByRole('combobox'), 'sub-stroke0001') + await user.click(screen.getByRole('button', { name: /run segmentation/i })) + + await waitFor(() => { + expect(screen.getByRole('alert')).toBeInTheDocument() + }) + + expect(screen.getByText(/segmentation failed/i)).toBeInTheDocument() + }) + + it('allows retry after error', async () => { + server.use(errorHandlers.segmentServerError) + const user = userEvent.setup() + + render() + + await waitFor(() => { + expect(screen.getByRole('combobox')).toBeInTheDocument() + }) + + await user.selectOptions(screen.getByRole('combobox'), 'sub-stroke0001') + await user.click(screen.getByRole('button', { name: /run segmentation/i })) + + await waitFor(() => { + expect(screen.getByRole('alert')).toBeInTheDocument() + }) + + // Reset to success handler + server.resetHandlers() + + // Retry + await user.click(screen.getByRole('button', { name: /run segmentation/i })) + + await waitFor(() => { + expect(screen.getByText('0.847')).toBeInTheDocument() + }) + + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) + }) + + describe('Multiple Runs', () => { + it('allows running segmentation on different cases', async () => { + const user = userEvent.setup() + render() + + await waitFor(() => { + expect(screen.getByRole('combobox')).toBeInTheDocument() + }) + + // First case + await user.selectOptions(screen.getByRole('combobox'), 'sub-stroke0001') + await user.click(screen.getByRole('button', { name: /run segmentation/i })) + + // Wait for first segmentation to complete + await waitFor(() => { + expect(screen.getByText('sub-stroke0001')).toBeInTheDocument() + }) + + // Wait for button to be ready again (not "Processing...") + await waitFor(() => { + expect(screen.getByRole('button', { name: /run segmentation/i })).toBeInTheDocument() + }) + + // Second case + await user.selectOptions(screen.getByRole('combobox'), 'sub-stroke0002') + await user.click(screen.getByRole('button', { name: /run segmentation/i })) + + await waitFor(() => { + expect(screen.getByText('sub-stroke0002')).toBeInTheDocument() + }) + }) + }) +}) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f5dd7cb13be3e5a73a0eab642e18447d35bd4fd1 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,65 @@ +import { useState } from 'react' +import { Layout } from './components/Layout' +import { CaseSelector } from './components/CaseSelector' +import { NiiVueViewer } from './components/NiiVueViewer' +import { MetricsPanel } from './components/MetricsPanel' +import { useSegmentation } from './hooks/useSegmentation' + +export default function App() { + const [selectedCase, setSelectedCase] = useState(null) + const { result, isLoading, error, runSegmentation } = useSegmentation() + + const handleRunSegmentation = async () => { + if (selectedCase) { + await runSegmentation(selectedCase) + } + } + + return ( + +
+ {/* Left Panel: Controls */} +
+ + + + + {error && ( +
+ {error} +
+ )} + + {result && } +
+ + {/* Right Panel: Viewer */} +
+ {result ? ( + + ) : ( +
+

+ Select a case and run segmentation to view results +

+
+ )} +
+
+
+ ) +} diff --git a/frontend/src/api/__tests__/client.test.ts b/frontend/src/api/__tests__/client.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..63ed9e9c45cce7a5bc7121158294b2bc31691dbf --- /dev/null +++ b/frontend/src/api/__tests__/client.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from 'vitest' +import { server } from '../../mocks/server' +import { errorHandlers } from '../../mocks/handlers' +import { apiClient } from '../client' + +describe('apiClient', () => { + describe('getCases', () => { + it('returns list of case IDs', async () => { + const result = await apiClient.getCases() + + expect(result.cases).toHaveLength(3) + expect(result.cases).toContain('sub-stroke0001') + }) + + it('throws ApiError on server error', async () => { + server.use(errorHandlers.casesServerError) + + await expect(apiClient.getCases()).rejects.toThrow(/failed to fetch cases/i) + }) + + it('throws ApiError on network error', async () => { + server.use(errorHandlers.casesNetworkError) + + await expect(apiClient.getCases()).rejects.toThrow() + }) + }) + + describe('runSegmentation', () => { + it('returns segmentation result', async () => { + const result = await apiClient.runSegmentation('sub-stroke0001') + + expect(result.caseId).toBe('sub-stroke0001') + expect(result.diceScore).toBe(0.847) + expect(result.volumeMl).toBe(15.32) + expect(result.dwiUrl).toContain('dwi.nii.gz') + expect(result.predictionUrl).toContain('prediction.nii.gz') + }) + + it('sends fast_mode=false parameter (slower processing)', async () => { + const result = await apiClient.runSegmentation('sub-stroke0001', false) + + // Mock returns 45.0s when fast_mode=false + expect(result.elapsedSeconds).toBe(45.0) + }) + + it('defaults fast_mode to true (faster processing)', async () => { + const result = await apiClient.runSegmentation('sub-stroke0001') + + // Mock returns 12.5s when fast_mode=true (the default) + expect(result.elapsedSeconds).toBe(12.5) + }) + + it('throws ApiError on server error', async () => { + server.use(errorHandlers.segmentServerError) + + await expect( + apiClient.runSegmentation('sub-stroke0001') + ).rejects.toThrow(/segmentation failed/i) + }) + }) +}) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000000000000000000000000000000000000..b5c9d21553b074ab6b99cf9cc7cee67f775a00d4 --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,84 @@ +import type { CasesResponse, SegmentResponse } from '../types' + +function getApiBase(): string { + const url = import.meta.env.VITE_API_URL + + // In production, VITE_API_URL must be set - fail fast with clear error + if (import.meta.env.PROD && !url) { + throw new Error( + 'VITE_API_URL environment variable is required in production. ' + + 'Set it to the backend API URL (e.g., https://your-app.hf.space).' + ) + } + + // In development, fall back to localhost + return url || 'http://localhost:7860' +} + +const API_BASE = getApiBase() + +export class ApiError extends Error { + status: number + detail?: string + + constructor(message: string, status: number, detail?: string) { + super(message) + this.name = 'ApiError' + this.status = status + this.detail = detail + } +} + +class ApiClient { + private baseUrl: string + + constructor(baseUrl: string) { + this.baseUrl = baseUrl + } + + async getCases(signal?: AbortSignal): Promise { + const response = await fetch(`${this.baseUrl}/api/cases`, { signal }) + + if (!response.ok) { + const error = await response.json().catch(() => ({})) + throw new ApiError( + `Failed to fetch cases: ${response.statusText}`, + response.status, + error.detail + ) + } + + return response.json() + } + + async runSegmentation( + caseId: string, + fastMode: boolean = true, + signal?: AbortSignal + ): Promise { + const response = await fetch(`${this.baseUrl}/api/segment`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + case_id: caseId, + fast_mode: fastMode, + }), + signal, + }) + + if (!response.ok) { + const error = await response.json().catch(() => ({})) + throw new ApiError( + `Segmentation failed: ${error.detail || response.statusText}`, + response.status, + error.detail + ) + } + + return response.json() + } +} + +export const apiClient = new ApiClient(API_BASE) diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..55e204e5fb299d66a61107dd94016a772a1546b1 --- /dev/null +++ b/frontend/src/api/index.ts @@ -0,0 +1 @@ +export { apiClient, ApiError } from './client' diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg new file mode 100644 index 0000000000000000000000000000000000000000..8e0e0f15c01fd50bca7314def56b275971fd3dee --- /dev/null +++ b/frontend/src/assets/react.svg @@ -0,0 +1 @@ + diff --git a/frontend/src/components/CaseSelector.tsx b/frontend/src/components/CaseSelector.tsx new file mode 100644 index 0000000000000000000000000000000000000000..433dfeecd82bbea38444fc99ea9b736272921cb5 --- /dev/null +++ b/frontend/src/components/CaseSelector.tsx @@ -0,0 +1,73 @@ +import { useEffect, useState } from 'react' +import { apiClient } from '../api/client' + +interface CaseSelectorProps { + selectedCase: string | null + onSelectCase: (caseId: string) => void +} + +export function CaseSelector({ selectedCase, onSelectCase }: CaseSelectorProps) { + const [cases, setCases] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + const abortController = new AbortController() + + const fetchCases = async () => { + try { + const data = await apiClient.getCases(abortController.signal) + setCases(data.cases) + } catch (err) { + // Ignore abort errors - component unmounted + if (err instanceof Error && err.name === 'AbortError') return + + const message = err instanceof Error ? err.message : 'Unknown error' + setError(`Failed to load cases: ${message}`) + } finally { + if (!abortController.signal.aborted) { + setIsLoading(false) + } + } + } + + fetchCases() + + return () => abortController.abort() + }, []) + + if (isLoading) { + return ( +
+

Loading cases...

+
+ ) + } + + if (error) { + return ( +
+

{error}

+
+ ) + } + + return ( +
+ + +
+ ) +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c6465cf679cdad9e07b482f8d8f7f91c50cd9b2b --- /dev/null +++ b/frontend/src/components/Layout.tsx @@ -0,0 +1,21 @@ +import type { ReactNode } from 'react' + +interface LayoutProps { + children: ReactNode +} + +export function Layout({ children }: LayoutProps) { + return ( +
+
+
+

Stroke Lesion Segmentation

+

+ DeepISLES segmentation on ISLES24 dataset +

+
+
+
{children}
+
+ ) +} diff --git a/frontend/src/components/MetricsPanel.tsx b/frontend/src/components/MetricsPanel.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d03847b283b33caa63ee78a586c6473fe17026c5 --- /dev/null +++ b/frontend/src/components/MetricsPanel.tsx @@ -0,0 +1,45 @@ +import type { Metrics } from '../types' + +interface MetricsPanelProps { + metrics: Metrics +} + +export function MetricsPanel({ metrics }: MetricsPanelProps) { + return ( +
+

Results

+ +
+
+ Case: + {metrics.caseId} +
+ + {metrics.diceScore !== null && ( +
+ Dice Score: + + {metrics.diceScore.toFixed(3)} + +
+ )} + + {metrics.volumeMl !== null && ( +
+ Volume: + + {metrics.volumeMl.toFixed(2)} mL + +
+ )} + +
+ Time: + + {metrics.elapsedSeconds.toFixed(1)}s + +
+
+
+ ) +} diff --git a/frontend/src/components/NiiVueViewer.tsx b/frontend/src/components/NiiVueViewer.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8b79482556efc8b139d7619a7ed21c29378bef26 --- /dev/null +++ b/frontend/src/components/NiiVueViewer.tsx @@ -0,0 +1,108 @@ +import { useRef, useEffect, useState } from 'react' +import { Niivue } from '@niivue/niivue' + +interface NiiVueViewerProps { + backgroundUrl: string + overlayUrl?: string + onError?: (error: string) => void +} + +export function NiiVueViewer({ backgroundUrl, overlayUrl, onError }: NiiVueViewerProps) { + const canvasRef = useRef(null) + const nvRef = useRef(null) + const onErrorRef = useRef(onError) + const [loadError, setLoadError] = useState(null) + + // Keep onError ref current without triggering effect re-runs + useEffect(() => { + onErrorRef.current = onError + }) + + // Effect 1: Mount/unmount - instantiate and cleanup NiiVue ONCE + useEffect(() => { + if (!canvasRef.current) return + + const nv = new Niivue({ + backColor: [0.05, 0.05, 0.05, 1], + show3Dcrosshair: true, + crosshairColor: [1, 0, 0, 0.5], + }) + nv.attachToCanvas(canvasRef.current) + nvRef.current = nv + + // Cleanup on unmount ONLY - CRITICAL: Release WebGL context + // Browsers limit WebGL contexts (~16 in Chrome). Without cleanup, + // navigating between cases will exhaust contexts and break the viewer. + return () => { + // Capture gl BEFORE cleanup (cleanup may null internal state) + const gl = nv.gl + try { + // NiiVue's cleanup() releases event listeners and observers + // See: https://niivue.github.io/niivue/devdocs/classes/Niivue.html#cleanup + nv.cleanup() + // Force WebGL context loss to free GPU memory immediately + if (gl) { + const ext = gl.getExtension('WEBGL_lose_context') + ext?.loseContext() + } + } catch { + // Ignore cleanup errors + } + nvRef.current = null + } + }, []) + + // Effect 2: URL changes - reload volumes on existing NiiVue instance + // Uses isCurrent flag to ignore stale loads when URLs change rapidly + useEffect(() => { + const nv = nvRef.current + if (!nv) return + + let isCurrent = true + + // Clear previous error before new load (valid pattern for async operations) + // eslint-disable-next-line react-hooks/set-state-in-effect + setLoadError(null) + + const volumes: Array<{ url: string; colormap: string; opacity: number }> = [ + { url: backgroundUrl, colormap: 'gray', opacity: 1 }, + ] + + if (overlayUrl) { + volumes.push({ + url: overlayUrl, + colormap: 'red', + opacity: 0.5, + }) + } + + // Load volumes with error handling - ignore stale results + nv.loadVolumes(volumes).catch((err: unknown) => { + if (!isCurrent) return // Ignore errors from stale loads + const message = err instanceof Error ? err.message : 'Failed to load volume' + setLoadError(message) + onErrorRef.current?.(message) + }) + + // Cleanup: mark this effect instance as stale + return () => { + isCurrent = false + } + }, [backgroundUrl, overlayUrl]) + + return ( +
+ + {loadError && ( +
+ Failed to load volume: {loadError} +
+ )} +
+ Scroll: Navigate slices + Drag: Adjust contrast + Right-click: Pan +
+
+ ) +} diff --git a/frontend/src/components/__tests__/CaseSelector.test.tsx b/frontend/src/components/__tests__/CaseSelector.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..99f47b5d272d3c289deff070bd3b9c4a3fdef317 --- /dev/null +++ b/frontend/src/components/__tests__/CaseSelector.test.tsx @@ -0,0 +1,112 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { server } from '../../mocks/server' +import { errorHandlers } from '../../mocks/handlers' +import { CaseSelector } from '../CaseSelector' + +describe('CaseSelector', () => { + const mockOnSelectCase = vi.fn() + + beforeEach(() => { + mockOnSelectCase.mockClear() + }) + + it('shows loading state initially', () => { + render( + + ) + + expect(screen.getByText(/loading/i)).toBeInTheDocument() + }) + + it('renders select after loading', async () => { + render( + + ) + + await waitFor(() => { + expect(screen.getByRole('combobox')).toBeInTheDocument() + }) + }) + + it('displays all cases as options', async () => { + render( + + ) + + await waitFor(() => { + expect(screen.getByRole('combobox')).toBeInTheDocument() + }) + + expect(screen.getByRole('option', { name: /sub-stroke0001/i })).toBeInTheDocument() + expect(screen.getByRole('option', { name: /sub-stroke0002/i })).toBeInTheDocument() + expect(screen.getByRole('option', { name: /sub-stroke0003/i })).toBeInTheDocument() + }) + + it('has placeholder option', async () => { + render( + + ) + + await waitFor(() => { + expect(screen.getByRole('combobox')).toBeInTheDocument() + }) + + expect(screen.getByRole('option', { name: /choose a case/i })).toBeInTheDocument() + }) + + it('calls onSelectCase when case selected', async () => { + const user = userEvent.setup() + + render( + + ) + + await waitFor(() => { + expect(screen.getByRole('combobox')).toBeInTheDocument() + }) + + await user.selectOptions(screen.getByRole('combobox'), 'sub-stroke0001') + + expect(mockOnSelectCase).toHaveBeenCalledWith('sub-stroke0001') + }) + + it('shows selected case value', async () => { + render( + + ) + + await waitFor(() => { + expect(screen.getByRole('combobox')).toHaveValue('sub-stroke0002') + }) + }) + + it('shows error state on API failure', async () => { + server.use(errorHandlers.casesServerError) + + render( + + ) + + await waitFor(() => { + expect(screen.getByText(/failed to load/i)).toBeInTheDocument() + }) + }) + + it('applies correct styling', async () => { + render( + + ) + + await waitFor(() => { + expect(screen.getByRole('combobox')).toBeInTheDocument() + }) + + const container = screen.getByRole('combobox').closest('div') + expect(container).toHaveClass('bg-gray-800') + }) +}) diff --git a/frontend/src/components/__tests__/Layout.test.tsx b/frontend/src/components/__tests__/Layout.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1f4aea6434859f523eae2b19d44028873a6bc35b --- /dev/null +++ b/frontend/src/components/__tests__/Layout.test.tsx @@ -0,0 +1,43 @@ +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import { Layout } from '../Layout' + +describe('Layout', () => { + it('renders header with title', () => { + render(Content) + + expect( + screen.getByRole('heading', { name: /stroke lesion segmentation/i }) + ).toBeInTheDocument() + }) + + it('renders subtitle', () => { + render(Content) + + expect(screen.getByText(/deepisles segmentation/i)).toBeInTheDocument() + }) + + it('renders children in main area', () => { + render( + +
Test Child
+
+ ) + + expect(screen.getByTestId('child')).toBeInTheDocument() + }) + + it('has accessible landmark structure', () => { + render(Content) + + expect(screen.getByRole('banner')).toBeInTheDocument() + expect(screen.getByRole('main')).toBeInTheDocument() + }) + + it('applies dark theme styling', () => { + render(Content) + + const container = screen.getByRole('banner').parentElement + expect(container).toHaveClass('bg-gray-950') + }) +}) diff --git a/frontend/src/components/__tests__/MetricsPanel.test.tsx b/frontend/src/components/__tests__/MetricsPanel.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2ac845a8b9673028eff80b295809bc7b967b5c35 --- /dev/null +++ b/frontend/src/components/__tests__/MetricsPanel.test.tsx @@ -0,0 +1,67 @@ +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import { MetricsPanel } from '../MetricsPanel' + +describe('MetricsPanel', () => { + const defaultMetrics = { + caseId: 'sub-stroke0001', + diceScore: 0.847, + volumeMl: 15.32, + elapsedSeconds: 12.5, + } + + it('renders results heading', () => { + render() + + expect( + screen.getByRole('heading', { name: /results/i }) + ).toBeInTheDocument() + }) + + it('displays case ID', () => { + render() + + expect(screen.getByText('sub-stroke0001')).toBeInTheDocument() + }) + + it('displays dice score with 3 decimal places', () => { + render() + + expect(screen.getByText('0.847')).toBeInTheDocument() + }) + + it('displays volume in mL with 2 decimal places', () => { + render() + + expect(screen.getByText('15.32 mL')).toBeInTheDocument() + }) + + it('displays elapsed time with 1 decimal place', () => { + render() + + expect(screen.getByText('12.5s')).toBeInTheDocument() + }) + + it('hides dice score row when null', () => { + render( + + ) + + expect(screen.queryByText(/dice score/i)).not.toBeInTheDocument() + }) + + it('hides volume row when null', () => { + render( + + ) + + expect(screen.queryByText(/volume/i)).not.toBeInTheDocument() + }) + + it('applies card styling', () => { + render() + + const panel = screen.getByRole('heading', { name: /results/i }).parentElement + expect(panel).toHaveClass('bg-gray-800', 'rounded-lg') + }) +}) diff --git a/frontend/src/components/__tests__/NiiVueViewer.test.tsx b/frontend/src/components/__tests__/NiiVueViewer.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c7683b89a21f371d1b8bb8e1e4c5d7c584efc6e6 --- /dev/null +++ b/frontend/src/components/__tests__/NiiVueViewer.test.tsx @@ -0,0 +1,160 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import { NiiVueViewer } from '../NiiVueViewer' + +// Store mock function references so tests can verify calls +const mockLoadVolumes = vi.fn().mockResolvedValue(undefined) +const mockCleanup = vi.fn() +const mockAttachToCanvas = vi.fn() +const mockLoseContext = vi.fn() + +// Mock the NiiVue module since it requires actual WebGL +vi.mock('@niivue/niivue', () => ({ + Niivue: class MockNiivue { + attachToCanvas = mockAttachToCanvas + loadVolumes = mockLoadVolumes + setSliceType = vi.fn() + cleanup = mockCleanup + gl = { + getExtension: vi.fn(() => ({ loseContext: mockLoseContext })), + } + opts = {} + }, +})) + +describe('NiiVueViewer', () => { + const defaultProps = { + backgroundUrl: 'http://localhost:7860/files/dwi.nii.gz', + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders canvas element', () => { + render() + + expect(document.querySelector('canvas')).toBeInTheDocument() + }) + + it('renders container with correct styling', () => { + render() + + const container = document.querySelector('canvas')?.parentElement + expect(container).toHaveClass('bg-gray-900') + }) + + it('renders help text for controls', () => { + render() + + expect(screen.getByText(/scroll/i)).toBeInTheDocument() + expect(screen.getByText(/drag/i)).toBeInTheDocument() + }) + + it('attaches NiiVue to canvas on mount', () => { + render() + + expect(mockAttachToCanvas).toHaveBeenCalled() + // Verify it was called with a canvas element + const arg = mockAttachToCanvas.mock.calls[0][0] + expect(arg).toBeInstanceOf(HTMLCanvasElement) + }) + + it('loads background volume on mount', () => { + render() + + expect(mockLoadVolumes).toHaveBeenCalledWith([ + { url: defaultProps.backgroundUrl, colormap: 'gray', opacity: 1 }, + ]) + }) + + it('loads both background and overlay when overlayUrl provided', () => { + const overlayUrl = 'http://localhost:7860/files/prediction.nii.gz' + + render( + + ) + + expect(mockLoadVolumes).toHaveBeenCalledWith([ + { url: defaultProps.backgroundUrl, colormap: 'gray', opacity: 1 }, + { url: overlayUrl, colormap: 'red', opacity: 0.5 }, + ]) + }) + + it('calls cleanup on unmount', () => { + const { unmount } = render() + + unmount() + + expect(mockCleanup).toHaveBeenCalled() + expect(mockLoseContext).toHaveBeenCalled() + }) + + it('sets canvas dimensions', () => { + render() + + const canvas = document.querySelector('canvas') + expect(canvas).toHaveClass('w-full', 'h-[500px]') + }) + + it('displays error when volume loading fails', async () => { + const errorMessage = 'Network error loading volume' + mockLoadVolumes.mockRejectedValueOnce(new Error(errorMessage)) + + render() + + // Wait for error to be displayed + const errorElement = await screen.findByText(/failed to load volume/i) + expect(errorElement).toBeInTheDocument() + expect(errorElement).toHaveTextContent(errorMessage) + }) + + it('calls onError callback when volume loading fails', async () => { + const errorMessage = 'Network error' + const onError = vi.fn() + mockLoadVolumes.mockRejectedValueOnce(new Error(errorMessage)) + + render() + + // Wait for error callback to be invoked + await vi.waitFor(() => { + expect(onError).toHaveBeenCalledWith(errorMessage) + }) + }) + + it('ignores errors from stale loads after URL change', async () => { + const onError = vi.fn() + // First load succeeds, second load fails slowly + let rejectSecondLoad: (error: Error) => void + mockLoadVolumes + .mockResolvedValueOnce(undefined) + .mockImplementationOnce(() => new Promise((_, reject) => { + rejectSecondLoad = reject + })) + + const { rerender } = render( + + ) + + // Change URL - starts second load + rerender( + + ) + + // Change URL again - makes second load stale + rerender( + + ) + + // Now reject the second load (stale) + rejectSecondLoad!(new Error('Stale load error')) + + // Wait a tick and verify onError was NOT called with stale error + await vi.waitFor(() => { + expect(onError).not.toHaveBeenCalledWith('Stale load error') + }) + }) +}) diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..4c5c982d2dbd7e5170cef890b6eec25eefe851b3 --- /dev/null +++ b/frontend/src/components/index.ts @@ -0,0 +1,4 @@ +export { Layout } from './Layout' +export { MetricsPanel } from './MetricsPanel' +export { CaseSelector } from './CaseSelector' +export { NiiVueViewer } from './NiiVueViewer' diff --git a/frontend/src/hooks/__tests__/useSegmentation.test.tsx b/frontend/src/hooks/__tests__/useSegmentation.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..770118e447ce9cc59203d983c5b3b893798fff7e --- /dev/null +++ b/frontend/src/hooks/__tests__/useSegmentation.test.tsx @@ -0,0 +1,96 @@ +import { describe, it, expect } from 'vitest' +import { renderHook, waitFor, act } from '@testing-library/react' +import { server } from '../../mocks/server' +import { errorHandlers } from '../../mocks/handlers' +import { useSegmentation } from '../useSegmentation' + +describe('useSegmentation', () => { + it('starts with null result and not loading', () => { + const { result } = renderHook(() => useSegmentation()) + + expect(result.current.result).toBeNull() + expect(result.current.isLoading).toBe(false) + expect(result.current.error).toBeNull() + }) + + it('sets loading state during segmentation', async () => { + const { result } = renderHook(() => useSegmentation()) + + act(() => { + result.current.runSegmentation('sub-stroke0001') + }) + + expect(result.current.isLoading).toBe(true) + + await waitFor(() => { + expect(result.current.isLoading).toBe(false) + }) + }) + + it('returns result on success', async () => { + const { result } = renderHook(() => useSegmentation()) + + await act(async () => { + await result.current.runSegmentation('sub-stroke0001') + }) + + expect(result.current.result).not.toBeNull() + expect(result.current.result?.metrics.caseId).toBe('sub-stroke0001') + expect(result.current.result?.metrics.diceScore).toBe(0.847) + expect(result.current.result?.dwiUrl).toContain('dwi.nii.gz') + }) + + it('sets error on failure', async () => { + server.use(errorHandlers.segmentServerError) + + const { result } = renderHook(() => useSegmentation()) + + await act(async () => { + await result.current.runSegmentation('sub-stroke0001') + }) + + expect(result.current.error).toMatch(/segmentation failed/i) + expect(result.current.result).toBeNull() + }) + + it('clears previous error on new request', async () => { + server.use(errorHandlers.segmentServerError) + const { result } = renderHook(() => useSegmentation()) + + // First request fails + await act(async () => { + await result.current.runSegmentation('sub-stroke0001') + }) + expect(result.current.error).not.toBeNull() + + // Reset to success handler + server.resetHandlers() + + // Second request succeeds + await act(async () => { + await result.current.runSegmentation('sub-stroke0001') + }) + + expect(result.current.error).toBeNull() + expect(result.current.result).not.toBeNull() + }) + + it('clears previous result on new request', async () => { + const { result } = renderHook(() => useSegmentation()) + + // First request + await act(async () => { + await result.current.runSegmentation('sub-stroke0001') + }) + expect(result.current.result).not.toBeNull() + + // Start second request - result should clear while loading + act(() => { + result.current.runSegmentation('sub-stroke0002') + }) + + // While loading, previous result is still available + // (or you could clear it - depends on UX preference) + expect(result.current.isLoading).toBe(true) + }) +}) diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..d93a11605b07db060681223c9b75a02e38252395 --- /dev/null +++ b/frontend/src/hooks/index.ts @@ -0,0 +1 @@ +export { useSegmentation } from './useSegmentation' diff --git a/frontend/src/hooks/useSegmentation.ts b/frontend/src/hooks/useSegmentation.ts new file mode 100644 index 0000000000000000000000000000000000000000..aaffd6a40869c0cdbc7dfbbf2bb654b2db41521a --- /dev/null +++ b/frontend/src/hooks/useSegmentation.ts @@ -0,0 +1,63 @@ +import { useState, useCallback, useRef } from 'react' +import { apiClient } from '../api/client' +import type { SegmentationResult } from '../types' + +export function useSegmentation() { + const [result, setResult] = useState(null) + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + + // Track the current request to prevent race conditions + // Each new request gets a unique token; only the latest request's results are applied + const currentRequestRef = useRef(0) + const abortControllerRef = useRef(null) + + const runSegmentation = useCallback(async (caseId: string, fastMode = true) => { + // Cancel any in-flight request + abortControllerRef.current?.abort() + const abortController = new AbortController() + abortControllerRef.current = abortController + + // Increment request token to track this request + const requestToken = ++currentRequestRef.current + + setIsLoading(true) + setError(null) + + try { + const data = await apiClient.runSegmentation(caseId, fastMode, abortController.signal) + + // Only apply results if this is still the current request + // Prevents stale responses from overwriting newer results + if (requestToken !== currentRequestRef.current) return + + setResult({ + dwiUrl: data.dwiUrl, + predictionUrl: data.predictionUrl, + metrics: { + caseId: data.caseId, + diceScore: data.diceScore, + volumeMl: data.volumeMl, + elapsedSeconds: data.elapsedSeconds, + }, + }) + } catch (err) { + // Ignore abort errors - user intentionally cancelled + if (err instanceof Error && err.name === 'AbortError') return + + // Only apply error if this is still the current request + if (requestToken !== currentRequestRef.current) return + + const message = err instanceof Error ? err.message : 'Unknown error' + setError(message) + setResult(null) + } finally { + // Only clear loading if this is still the current request + if (requestToken === currentRequestRef.current) { + setIsLoading(false) + } + } + }, []) + + return { result, isLoading, error, runSegmentation } +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000000000000000000000000000000000000..f1d8c73cdcf9eaacb01fec99963ad78d591305ae --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1 @@ +@import "tailwindcss"; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000000000000000000000000000000000000..bef5202a32cbd0632c43de40f6e908532903fd42 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App.tsx' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/frontend/src/mocks/handlers.ts b/frontend/src/mocks/handlers.ts new file mode 100644 index 0000000000000000000000000000000000000000..3fcfc7b07ac19cd1652b0e4d79be5f7b2c3801ac --- /dev/null +++ b/frontend/src/mocks/handlers.ts @@ -0,0 +1,52 @@ +import { http, HttpResponse, delay } from 'msw' + +const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:7860' + +export const handlers = [ + http.get(`${API_BASE}/api/cases`, async () => { + await delay(100) + return HttpResponse.json({ + cases: ['sub-stroke0001', 'sub-stroke0002', 'sub-stroke0003'], + }) + }), + + http.post(`${API_BASE}/api/segment`, async ({ request }) => { + const body = (await request.json()) as { case_id: string; fast_mode?: boolean } + await delay(200) + return HttpResponse.json({ + caseId: body.case_id, + diceScore: 0.847, + volumeMl: 15.32, + // Reflect fast_mode in response - slower when fast_mode=false + elapsedSeconds: body.fast_mode === false ? 45.0 : 12.5, + dwiUrl: `${API_BASE}/files/dwi.nii.gz`, + predictionUrl: `${API_BASE}/files/prediction.nii.gz`, + }) + }), +] + +// Error handlers for testing error states +export const errorHandlers = { + casesServerError: http.get(`${API_BASE}/api/cases`, () => { + return HttpResponse.json( + { detail: 'Internal server error' }, + { status: 500 } + ) + }), + + casesNetworkError: http.get(`${API_BASE}/api/cases`, () => { + return HttpResponse.error() + }), + + segmentServerError: http.post(`${API_BASE}/api/segment`, () => { + return HttpResponse.json( + { detail: 'Segmentation failed: out of memory' }, + { status: 500 } + ) + }), + + segmentTimeout: http.post(`${API_BASE}/api/segment`, async () => { + await delay(30000) + return HttpResponse.json({ detail: 'Timeout' }, { status: 504 }) + }), +} diff --git a/frontend/src/mocks/server.ts b/frontend/src/mocks/server.ts new file mode 100644 index 0000000000000000000000000000000000000000..86f7d6154ac757b887259351249eba046341b499 --- /dev/null +++ b/frontend/src/mocks/server.ts @@ -0,0 +1,4 @@ +import { setupServer } from 'msw/node' +import { handlers } from './handlers' + +export const server = setupServer(...handlers) diff --git a/frontend/src/test/fixtures.ts b/frontend/src/test/fixtures.ts new file mode 100644 index 0000000000000000000000000000000000000000..c3c81120a781483af549db5825d3097096e68161 --- /dev/null +++ b/frontend/src/test/fixtures.ts @@ -0,0 +1,22 @@ +import type { SegmentationResult, CasesResponse } from '../types' + +export const mockCases: string[] = [ + 'sub-stroke0001', + 'sub-stroke0002', + 'sub-stroke0003', +] + +export const mockCasesResponse: CasesResponse = { + cases: mockCases, +} + +export const mockSegmentationResult: SegmentationResult = { + dwiUrl: 'http://localhost:7860/files/dwi.nii.gz', + predictionUrl: 'http://localhost:7860/files/prediction.nii.gz', + metrics: { + caseId: 'sub-stroke0001', + diceScore: 0.847, + volumeMl: 15.32, + elapsedSeconds: 12.5, + }, +} diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts new file mode 100644 index 0000000000000000000000000000000000000000..3c01cfd249c79d429eba79106b10f84af2505860 --- /dev/null +++ b/frontend/src/test/setup.ts @@ -0,0 +1,151 @@ +import '@testing-library/jest-dom/vitest' +import { cleanup } from '@testing-library/react' +import { afterEach, beforeAll, afterAll, vi } from 'vitest' +import { server } from '../mocks/server' + +// Establish API mocking before all tests +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) + +// Clean up after each test +afterEach(() => { + cleanup() + server.resetHandlers() +}) + +// Clean up after all tests +afterAll(() => server.close()) + +// Mock ResizeObserver (needed for some UI components) +global.ResizeObserver = class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} + +// Mock WebGL2 context for NiiVue +// NiiVue requires specific extensions for float textures (overlays) +// See: https://github.com/niivue/niivue#browser-requirements +const mockExtensions: Record = { + // Required for float textures (overlay rendering) + EXT_color_buffer_float: {}, + OES_texture_float_linear: {}, + // Required for WebGL context management + WEBGL_lose_context: { + loseContext: vi.fn(), + restoreContext: vi.fn(), + }, + // Optional but commonly requested + EXT_texture_filter_anisotropic: { + TEXTURE_MAX_ANISOTROPY_EXT: 0x84fe, + MAX_TEXTURE_MAX_ANISOTROPY_EXT: 0x84ff, + }, + WEBGL_debug_renderer_info: { + UNMASKED_VENDOR_WEBGL: 0x9245, + UNMASKED_RENDERER_WEBGL: 0x9246, + }, +} + +const mockWebGL2Context = { + canvas: null as HTMLCanvasElement | null, + drawingBufferWidth: 640, + drawingBufferHeight: 480, + createShader: vi.fn(() => ({})), + shaderSource: vi.fn(), + compileShader: vi.fn(), + getShaderParameter: vi.fn(() => true), + getShaderInfoLog: vi.fn(() => ''), + createProgram: vi.fn(() => ({})), + attachShader: vi.fn(), + linkProgram: vi.fn(), + getProgramParameter: vi.fn(() => true), + getProgramInfoLog: vi.fn(() => ''), + useProgram: vi.fn(), + getAttribLocation: vi.fn(() => 0), + getUniformLocation: vi.fn(() => ({})), + createBuffer: vi.fn(() => ({})), + bindBuffer: vi.fn(), + bufferData: vi.fn(), + enableVertexAttribArray: vi.fn(), + vertexAttribPointer: vi.fn(), + createTexture: vi.fn(() => ({})), + bindTexture: vi.fn(), + texParameteri: vi.fn(), + texParameterf: vi.fn(), + texImage2D: vi.fn(), + texImage3D: vi.fn(), + texStorage2D: vi.fn(), + texStorage3D: vi.fn(), + texSubImage2D: vi.fn(), + texSubImage3D: vi.fn(), + activeTexture: vi.fn(), + generateMipmap: vi.fn(), + uniform1i: vi.fn(), + uniform1f: vi.fn(), + uniform2f: vi.fn(), + uniform2fv: vi.fn(), + uniform3f: vi.fn(), + uniform3fv: vi.fn(), + uniform4f: vi.fn(), + uniform4fv: vi.fn(), + uniformMatrix4fv: vi.fn(), + viewport: vi.fn(), + scissor: vi.fn(), + clear: vi.fn(), + clearColor: vi.fn(), + clearDepth: vi.fn(), + enable: vi.fn(), + disable: vi.fn(), + blendFunc: vi.fn(), + blendFuncSeparate: vi.fn(), + depthFunc: vi.fn(), + depthMask: vi.fn(), + cullFace: vi.fn(), + drawArrays: vi.fn(), + drawElements: vi.fn(), + // CRITICAL: Return stub extensions for NiiVue float texture support + getExtension: vi.fn((name: string) => mockExtensions[name] || null), + getParameter: vi.fn((pname: number) => { + // Return reasonable defaults for common parameter queries + if (pname === 0x0d33) return 16384 // MAX_TEXTURE_SIZE + if (pname === 0x8073) return 2048 // MAX_3D_TEXTURE_SIZE + if (pname === 0x851c) return 16 // MAX_TEXTURE_IMAGE_UNITS + return 0 + }), + getSupportedExtensions: vi.fn(() => Object.keys(mockExtensions)), + pixelStorei: vi.fn(), + readPixels: vi.fn(), + createFramebuffer: vi.fn(() => ({})), + bindFramebuffer: vi.fn(), + framebufferTexture2D: vi.fn(), + checkFramebufferStatus: vi.fn(() => 36053), // FRAMEBUFFER_COMPLETE + createRenderbuffer: vi.fn(() => ({})), + bindRenderbuffer: vi.fn(), + renderbufferStorage: vi.fn(), + framebufferRenderbuffer: vi.fn(), + deleteTexture: vi.fn(), + deleteBuffer: vi.fn(), + deleteProgram: vi.fn(), + deleteShader: vi.fn(), + deleteFramebuffer: vi.fn(), + deleteRenderbuffer: vi.fn(), + createVertexArray: vi.fn(() => ({})), + bindVertexArray: vi.fn(), + deleteVertexArray: vi.fn(), + flush: vi.fn(), + finish: vi.fn(), + isContextLost: vi.fn(() => false), +} + +// Override getContext to return WebGL mock - uses type assertion for test mocking +// eslint-disable-next-line @typescript-eslint/no-explicit-any +;(HTMLCanvasElement.prototype as any).getContext = function ( + contextType: string +): RenderingContext | null { + if (contextType === 'webgl2' || contextType === 'webgl') { + return { + ...mockWebGL2Context, + canvas: this, + } as unknown as WebGL2RenderingContext + } + return null +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..f0dc1c579450910b1244007cca7e65f40dca737e --- /dev/null +++ b/frontend/src/types/index.ts @@ -0,0 +1,25 @@ +export interface Metrics { + caseId: string + diceScore: number | null + volumeMl: number | null + elapsedSeconds: number +} + +export interface SegmentationResult { + dwiUrl: string + predictionUrl: string + metrics: Metrics +} + +export interface CasesResponse { + cases: string[] +} + +export interface SegmentResponse { + caseId: string + diceScore: number | null + volumeMl: number | null + elapsedSeconds: number + dwiUrl: string + predictionUrl: string +} diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000000000000000000000000000000000000..22c08a726590227a222ec064ffa6fe80c8c36576 --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "types": ["vite/client"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"], + "exclude": ["src/test", "src/mocks", "src/**/*.test.tsx", "src/**/*.test.ts"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..01490aab43e52fabf4e1c19d986c32c9ecdf1a5b --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,8 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" }, + { "path": "./tsconfig.test.json" } + ] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000000000000000000000000000000000000..7ad54d46ba3147b1feec618d3e19cc306ab19d35 --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts", "vitest.config.ts"] +} diff --git a/frontend/tsconfig.test.json b/frontend/tsconfig.test.json new file mode 100644 index 0000000000000000000000000000000000000000..846fa8eb340d2dcc1ecd9b5f599ae088892f5d6b --- /dev/null +++ b/frontend/tsconfig.test.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.test.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "types": ["vite/client", "vitest/globals", "@testing-library/jest-dom", "node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src/test", "src/mocks", "src/**/*.test.tsx", "src/**/*.test.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000000000000000000000000000000000000..7c8cea6b7d0c593d4ba7d17b366211b10f717729 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' + +export default defineConfig({ + plugins: [react(), tailwindcss()], + build: { + outDir: 'dist', + }, +}) diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts new file mode 100644 index 0000000000000000000000000000000000000000..24c457528087f201396b10bddd96228062f50f60 --- /dev/null +++ b/frontend/vitest.config.ts @@ -0,0 +1,33 @@ +import { defineConfig, mergeConfig } from 'vitest/config' +import viteConfig from './vite.config' + +export default mergeConfig( + viteConfig, + defineConfig({ + test: { + globals: true, + environment: 'jsdom', + setupFiles: ['./src/test/setup.ts'], + include: ['src/**/*.{test,spec}.{ts,tsx}'], + exclude: ['node_modules', 'e2e'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + include: ['src/**/*.{ts,tsx}'], + exclude: [ + 'src/**/*.test.{ts,tsx}', + 'src/test/**', + 'src/mocks/**', + 'src/main.tsx', + 'src/vite-env.d.ts', + ], + thresholds: { + statements: 80, + branches: 75, + functions: 80, + lines: 80, + }, + }, + }, + }) +) diff --git a/src/stroke_deepisles_demo/ui/components.py b/src/stroke_deepisles_demo/ui/components.py index d9eb2e4709fe83919f4fa798301d202546886e05..f9a6c8a38e12e9ef96ed6efb392eb9d913fb6ec3 100644 --- a/src/stroke_deepisles_demo/ui/components.py +++ b/src/stroke_deepisles_demo/ui/components.py @@ -3,8 +3,9 @@ from __future__ import annotations import gradio as gr -from gradio_niivueviewer import NiiVueViewer +# Disabled: Gradio NiiVue viewer replaced with standalone React frontend (see frontend/ directory) +# Original: from gradio_niivueviewer import NiiVueViewer from stroke_deepisles_demo.core.config import get_settings from stroke_deepisles_demo.core.logging import get_logger @@ -41,11 +42,11 @@ def create_results_display() -> dict[str, gr.components.Component]: with gr.Group(): with gr.Tabs(): with gr.Tab("Interactive 3D"): - # NiiVue 3D viewer Custom Component - # See: docs/specs/28-gradio-custom-component-niivue.md - niivue_viewer = NiiVueViewer( - label="Interactive 3D Viewer", - height=500, + # Disabled: Gradio NiiVue viewer replaced with React frontend + # See frontend/ directory for the new NiiVue implementation + niivue_viewer = gr.JSON( + label="NiiVue Data (React frontend active)", + value=None, ) with gr.Tab("Static Report"):