diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0ed4cfb..11ad197 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,9 +33,43 @@ jobs: python-version-file: ".python-version" cache: pip + - name: Set up Node + uses: actions/setup-node@v5 + with: + node-version: 24 + cache: npm + + - name: Install frontend deps + run: npm ci + - name: Run pre-commit uses: pre-commit/action@v3.0.1 + frontend: + name: Frontend + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v7 + + - name: Set up Node + uses: actions/setup-node@v5 + with: + node-version: 24 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Typecheck + run: npx tsc --noEmit + + - name: Unit tests + run: npm run test:frontend + + - name: Build + run: npm run build + test: name: Test runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index e042944..10ec52c 100644 --- a/.gitignore +++ b/.gitignore @@ -229,3 +229,9 @@ node_modules/ src/meshcore_hub/web/static/vendor/ src/meshcore_hub/web/static/dist/ src/meshcore_hub/web/static/css/tailwind.css + +# Playwright e2e artifacts +/e2e/.auth/ +/e2e/test-results/ +/e2e/playwright-report/ +/e2e/blob-report/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1f49c18..c6b1462 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -41,3 +41,15 @@ repos: - alembic>=1.7.0 - types-paho-mqtt>=1.6.0 - types-PyYAML>=6.0.0 + + # Frontend TypeScript gate. Runs in the host node toolchain (language: system) + # so it resolves types from the project's node_modules — requires `npm install`. + # `pass_filenames: false`: tsc typechecks the whole project (tsconfig include). + - repo: local + hooks: + - id: frontend-typecheck + name: frontend typecheck (tsc --noEmit) + entry: npm run typecheck + language: system + pass_filenames: false + files: '(spa-react/.*\.tsx?$|^tsconfig\.json$|^package(-lock)?\.json$)' diff --git a/AGENTS.md b/AGENTS.md index acb911b..2057401 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,7 @@ ``` `--no-cov` skips coverage for speed; the pipe surfaces only the pass/fail summary. - Use Python (version in `.python-version`); activate a venv in `.venv` before running pytest, pre-commit, or alembic locally. -- **All other operations run inside the compose stack** — never invoke `meshcore-hub` or `npm` directly on the host; build/run/exec via `docker compose` (see Development). +- **Application operations run inside the compose stack** — never invoke `meshcore-hub` directly on the host; build/run/exec via `docker compose` (see Development). The frontend `npm`/`vite`/`tsc` toolchain is the exception — it runs on the host (see Frontend). - **Never `git push` without explicit confirmation** — staging and committing discrete changes is fine. - **Never build the Docker images or run `make build` / `make up`** — the user builds manually to test. Stop after code changes + tests + pre-commit pass. - **Always generate random Alembic revision IDs** — use `python -c "import secrets; print(secrets.token_hex(6))"` or let `alembic revision` auto-generate. Never hand-pick sequential or guessable IDs like `a1b2c3d4e5f6` — they collide with existing migrations and cause cycle errors at upgrade time. @@ -40,6 +40,42 @@ docker compose -f docker-compose.yml -f docker-compose.dev.yml --profile core ex # Shorthands (Makefile, mqtt+core profiles): make build | make up | make down | make logs ``` +## Frontend (React) + +The web UI is a **React 19 + TypeScript + Vite** SPA in +`src/meshcore_hub/web/static/js/spa-react/` (alias `@/` → that dir). The Jinja2 shell +(`web/templates/spa.html`) renders only SEO/`window.__APP_CONFIG__`/footer; React renders the +navbar, banners, and routed pages into `
`. **Frontend tooling runs on the host** +(not in Docker): `npm install`, `npm run build` (Tailwind → vendor fonts → `vite build` → +`static/dist/` + `assets.json`), `npx tsc --noEmit` (the TS gate — also run by the +`frontend-typecheck` pre-commit hook; there is no JS *linter* in pre-commit), and +`npm run test:frontend` (vitest). The Vite build is required to serve the UI; +there is no fallback bundle. + +```bash +npm install # host: install frontend deps +npm run build # host: produce static/dist/ + assets.json +npx tsc --noEmit # host: typecheck (must be clean) +npm run test:frontend # host: vitest unit + component tests +``` + +- Charts: **react-chartjs-2** — typed config builders in `utils/charts.ts`, wrappers in + `components/charts/Charts.tsx` (imports `chart.js/auto`). +- Maps: **react-leaflet** (`MapPage.tsx`, `NodeDetail.tsx`); both `import "leaflet/dist/leaflet.css"`. + That CSS ships in the Vite bundle, which `spa.html` loads in `` **before** `app.css` so + the dark-mode map overrides win — don't reorder those ``s. +- QR codes: **react-qr-code**. +- Navbar/shell: React (`components/Navbar.tsx`, `ThemeToggle.tsx`, `Announcements.tsx`, + `hooks/useNavItems.tsx`); nav uses react-router `NavLink` (client-side nav). Feature flags, + custom pages, and announcements all come from `window.__APP_CONFIG__`. +- Page conventions: `useSearchParams()` for filters/pagination/sort, typed `apiGet()` with an + `AbortController` in `useEffect`, `usePageTitle('entities.x')`, shared components + (`Pagination`, `FilterForm`, `StatCard`, `NodeDisplay`, etc.). +- Tests: **vitest** + `@testing-library/react` (`*.test.ts(x)` next to code; setup in + `spa-react/test/`). Python web tests assert the embedded `__APP_CONFIG__` + (`tests/test_web/conftest.py::get_app_config`), not server-rendered nav HTML. +- Only **fonts** are vendored (`build.js` copies them); chart/map/QR libs are bundled by Vite. + ## Tests & Quality Coverage is **opt-in**; add `--cov=meshcore_hub` (or `make test-cov`) when you want it. The dev loop defaults to no coverage and parallel across CPU cores. @@ -49,9 +85,10 @@ Coverage is **opt-in**; add `--cov=meshcore_hub` (or `make test-cov`) when you w pytest -nauto --no-cov 2>&1 | grep -iE "passed|failed" | tail -3 # Makefile shorthands -make test # pytest -nauto --no-cov (parallel dev loop) -make test-cov # full run with coverage report -make test-unit # parallel, fast unit suites only (skips e2e) +make test # backend (pytest -nauto --no-cov) then frontend vitest +make test-cov # full backend run with coverage report +make test-unit # parallel, fast unit suites only (skips e2e) +make test-frontend # frontend vitest only (npm run test:frontend) # Targeted by component (run only what you changed) pytest --no-cov tests/test_web/ # templates, static JS, web routes @@ -66,6 +103,38 @@ pytest --no-cov pre-commit run --all-files ``` +Browser E2E lives in **`e2e/`** (Playwright, headless Chromium) and replaces the +old Python e2e suite. It runs against a **throwaway stack** (`e2e/docker-compose.test.yml`) +with its own ephemeral Postgres and isolated volumes — it never touches the dev +database. Like the rest of the stack, **the assistant never builds/runs these +images**; the user does. + +```bash +npx playwright install chromium # one-time browser binary (host) +make e2e-build && make e2e-up # user: build + start mqtt/pg/migrate/collector/api/web +make e2e-test # user: seeds via e2e/seed_data.py, then runs the suite +make e2e-down # user: tear down (destroys the throwaway DB) +npm run typecheck:e2e # assistant: typecheck the e2e TS (safe to run) +npx playwright test --config=e2e/playwright.config.ts --list # assistant: verify collection +``` + +Design notes when extending the suite: +- **Auth is forged, not logged in.** No mock IdP exists; the web tier fully trusts + the signed `meshcore-session` cookie. `e2e/mint_session.py` (itsdangerous, run + with `.venv` python) mints admin/member cookies using the stack's + `OIDC_SESSION_SECRET=test-session-secret`; global setup writes them to + `e2e/.auth/*.json` and specs opt in via `test.use({ storageState })`. OIDC is + enabled in the test stack (which also unlocks the Members feature). +- **Data is deterministic.** `e2e/seed_data.py` clears + recreates fixed rows + (nodes/observers with `area` tags, adverts, messages on channel idx 17 + the + "E2E General" custom channel, raw packets + path hops keyed to node prefixes, + a route + health, profiles + adoptions) using recent timestamps (7-day windows). +- **Single shared backend:** `workers: 1`, `fullyParallel: false`; routes/profile + specs are `describe.serial`. `WEB_AUTO_REFRESH_SECONDS=2` makes polling assertable. +- Selectors rely on purposeful `data-testid`s (theme/auto-refresh toggles, observer + area badges, path-hop badge + popover, route modal fields, nav/hero/member/list + rows) added to the React components. + ## Database & Ops The default backend is **SQLite** (zero-config, file at `${DATA_HOME}/collector/meshcore.db`). **PostgreSQL** is also supported via `DATABASE_BACKEND=postgres` — see `docs/database.md` for the full backend reference, production provisioning, and schema-per-instance setup. Migrations are backend-agnostic; the commands below work for both. diff --git a/Dockerfile b/Dockerfile index 3b453e8..613e62c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,7 +11,7 @@ WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci -COPY build.js ./ +COPY build.js vite.config.ts tsconfig.json ./ COPY src/meshcore_hub/web/static/css/input.css ./src/meshcore_hub/web/static/css/input.css COPY src/meshcore_hub/web/templates/ ./src/meshcore_hub/web/templates/ COPY src/meshcore_hub/web/static/js/ ./src/meshcore_hub/web/static/js/ diff --git a/Makefile b/Makefile index a54cc4b..afd09c1 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,8 @@ COMPOSE_FILES = -f docker-compose.yml -f docker-compose.dev.yml VOLUMES = $(COMPOSE_PROJECT_NAME)_data $(COMPOSE_PROJECT_NAME)_mqtt_data \ $(COMPOSE_PROJECT_NAME)_observer_data -.PHONY: build up down logs backup restore test test-cov test-unit +.PHONY: build up down logs backup restore test test-cov test-unit test-frontend \ + e2e-build e2e-up e2e-down e2e-seed e2e-test build: docker compose $(COMPOSE_FILES) --profile all build --no-cache @@ -36,11 +37,38 @@ restore: # --- Tests --------------------------------------------------------------- # Coverage is opt-in (use test-cov). Dev loop runs in parallel across cores. +# `test` runs the backend suite then the frontend (vitest) suite. test: pytest -nauto --no-cov + $(MAKE) test-frontend test-cov: pytest --cov=meshcore_hub --cov-report=term-missing test-unit: pytest -nauto --no-cov tests/test_common/ tests/test_api/ tests/test_collector/ tests/test_web/ + +test-frontend: + npm run test:frontend + +# --- E2E (Playwright) --------------------------------------------------- +# Self-contained throwaway stack (own ephemeral Postgres, isolated volumes). +# make e2e-build && make e2e-up # start the stack (build first time) +# make e2e-test # seeds data, then runs the Playwright suite +# make e2e-down # tears everything down (destroys the DB) +E2E_COMPOSE = docker compose -f e2e/docker-compose.test.yml + +e2e-build: + $(E2E_COMPOSE) build + +e2e-up: + $(E2E_COMPOSE) up -d + +e2e-down: + $(E2E_COMPOSE) down -v --remove-orphans + +e2e-seed: + $(E2E_COMPOSE) exec -T collector python /seed_data.py + +e2e-test: + npm run test:e2e diff --git a/README.md b/README.md index 936a4b3..df25858 100644 --- a/README.md +++ b/README.md @@ -247,7 +247,7 @@ meshcore-hub api meshcore-hub web ``` -> **Note:** `npm run build` compiles Tailwind CSS and copies vendor libraries (lit-html, Leaflet, Chart.js, QRCode.js) into `src/meshcore_hub/web/static/vendor/`. This step is required before the web dashboard will render correctly. In Docker, this happens automatically during the build. +> **Note:** `npm run build` builds the React SPA via Vite (Tailwind CSS, vendor fonts, TypeScript/React bundle) into `src/meshcore_hub/web/static/dist/`. Chart.js, Leaflet, and QR libraries are bundled by Vite; only fonts are vendored. This step is required before the web dashboard will render correctly. In Docker, this happens automatically during the build. ### Running Tests @@ -265,6 +265,27 @@ pytest tests/test_api/test_nodes.py pytest -k "test_list" ``` +### End-to-End Tests (Playwright) + +Browser E2E tests live in `e2e/` and run against a self-contained throwaway +stack (its own ephemeral Postgres, isolated volumes — never the dev database): + +```bash +npx playwright install chromium # one-time: browser binary + +make e2e-build # build the images (first time / after changes) +make e2e-up # start mqtt + postgres + migrate + collector + api + web +make e2e-test # seeds deterministic data, then runs the suite +make e2e-down # tear down (destroys the throwaway database) + +npm run typecheck:e2e # typecheck the e2e suite +``` + +The Playwright global setup seeds the database (via `e2e/seed_data.py` inside +the collector container), waits for the web service, and forges signed +`meshcore-session` cookies (admin + member) so authenticated/admin flows can be +tested without a real OIDC provider (`e2e/mint_session.py`). + ### Code Quality ```bash @@ -297,8 +318,8 @@ meshcore-hub/ │ ├── templates/ # Jinja2 templates (SPA shell) │ └── static/ │ ├── css/ # Stylesheets (app.css, input.css, built tailwind.css) -│ ├── vendor/ # Vendored JS/CSS libraries (built by npm run build) -│ ├── js/spa/ # SPA frontend (ES modules, lit-html) +│ ├── vendor/ # Vendored fonts (copied by npm run build) +│ ├── js/spa-react/ # SPA frontend (React 19 + TypeScript + Vite) │ └── locales/ # Translation files (en.json) ├── tests/ # Test suite ├── alembic/ # Database migrations @@ -318,8 +339,8 @@ meshcore-hub/ │ └── images/ # Custom images (logo.svg/png/jpg/jpeg/webp replace default logo) ├── data/ # Runtime data directory (DATA_HOME, created at runtime) ├── Dockerfile # Docker build configuration (multi-stage: Node.js frontend + Python) -├── package.json # Frontend build dependencies (Tailwind, DaisyUI, lit-html, etc.) -├── build.js # Frontend build script (Tailwind CLI + vendor copy) +├── package.json # Frontend build dependencies (React 19, Vite, TypeScript, Tailwind, DaisyUI) +├── build.js # Frontend build script (Tailwind CLI + vendor fonts + Vite build + assets.json) ├── docker-compose.yml # Docker Compose base config ├── docker-compose.dev.yml # Development overrides (port mappings) ├── docker-compose.prod.yml # Production overrides (proxy network) diff --git a/alembic/versions/20260719_1951_5e3b712ccf10_route_health_consolidated.py b/alembic/versions/20260719_1951_5e3b712ccf10_route_health_consolidated.py index 8ab3fe0..867df7b 100644 --- a/alembic/versions/20260719_1951_5e3b712ccf10_route_health_consolidated.py +++ b/alembic/versions/20260719_1951_5e3b712ccf10_route_health_consolidated.py @@ -531,8 +531,17 @@ def upgrade() -> None: # there are zero routes, so this is effectively a no-op; the loop is # retained so a restore from a dev backup that DOES have routes # backfills correctly. + # + # Runs inside a SAVEPOINT: the backfill imports the live ORM models, + # which can reference columns added by later migrations (e.g. + # routes.max_path_length). On Postgres a failed statement aborts the + # whole transaction, so without the savepoint the swallowed error would + # still kill the subsequent alembic_version stamp. Rolling back to the + # savepoint leaves the outer migration transaction healthy on both + # backends. try: - _backfill_history() + with conn.begin_nested(): + _backfill_history() except Exception as e: # noqa: BLE001 — never abort the migration print(f"[route health precompute] backfill skipped: {e}") diff --git a/build.js b/build.js index 6d3e869..5d71f0c 100644 --- a/build.js +++ b/build.js @@ -35,16 +35,6 @@ execSync( console.log("Copying vendor files..."); -vendor("leaflet", ["dist/leaflet.css", "dist/leaflet.js", "dist/leaflet.js.map"], "leaflet"); -mkdirSync(join(VENDOR, "leaflet", "images"), { recursive: true }); -cpSync( - join("node_modules", "leaflet", "dist", "images"), - join(VENDOR, "leaflet", "images"), - { recursive: true }, -); - -vendor("chart.js", ["dist/chart.umd.min.js"], "chart.js"); -vendor("qrcodejs", ["qrcode.min.js"], "qrcodejs"); vendor( "@fontsource-variable/ibm-plex-sans", [ @@ -62,38 +52,35 @@ vendor( "fonts", ); -console.log("Bundling SPA with esbuild..."); -mkdirSync(DIST, { recursive: true }); +console.log("Bundling SPA with Vite..."); +execSync("npx vite build", { stdio: "inherit" }); -const metafilePath = join(DIST, "meta.json"); -execSync( - `npx esbuild ${join(STATIC, "js", "spa", "app.js")}` + - ` --bundle --format=esm --splitting --minify` + - ` --outdir=${DIST}` + - ` --entry-names=[name].[hash]` + - ` --chunk-names=chunks/[name].[hash]` + - ` --metafile=${metafilePath}`, - { stdio: "inherit" }, -); +// Vite emits a copy of the input HTML preserving its path relative to the +// project root (dist/src/…/index.html). The Jinja2 template is the real +// HTML shell, so remove the artifact. +import { rmSync } from "node:fs"; +const staleHtmlDir = join(DIST, "src"); +if (existsSync(staleHtmlDir)) { + rmSync(staleHtmlDir, { recursive: true, force: true }); +} console.log("Generating assets manifest..."); -const meta = JSON.parse(readFileSync(metafilePath, "utf-8")); +const viteManifestPath = join(DIST, ".vite", "manifest.json"); const assets = {}; -for (const [outputPath, info] of Object.entries(meta.outputs)) { - if (!info.entryPoint) continue; - const entryName = info.entryPoint.split("/").pop().replace(/\.js$/, ".js"); - const fileName = outputPath.split("/").pop(); - assets[entryName] = fileName; +if (existsSync(viteManifestPath)) { + const viteManifest = JSON.parse(readFileSync(viteManifestPath, "utf-8")); + for (const [, info] of Object.entries(viteManifest)) { + if (!info.isEntry) continue; + assets["app.js"] = info.file; + if (info.css && info.css.length > 0) { + assets["app.css"] = info.css[0]; + } + } } -const vendorFiles = { - "leaflet.css": join(VENDOR, "leaflet", "leaflet.css"), - "leaflet.js": join(VENDOR, "leaflet", "leaflet.js"), - "chart.umd.min.js": join(VENDOR, "chart.js", "chart.umd.min.js"), - "qrcode.min.js": join(VENDOR, "qrcodejs", "qrcode.min.js"), -}; +const vendorFiles = {}; const vendorHashes = {}; for (const [name, path] of Object.entries(vendorFiles)) { diff --git a/docs/content.md b/docs/content.md index 44675b6..d774378 100644 --- a/docs/content.md +++ b/docs/content.md @@ -79,21 +79,24 @@ The markdown content is rendered as-is, so include your own `# Heading` if desir ### Supported Markdown Features -Pages are rendered with [Python-Markdown](https://python-markdown.github.io/) with the following extensions enabled: +Pages are shipped as raw markdown and rendered client-side by the React SPA +(`react-markdown` + `remark-gfm`). Raw HTML in the source is **escaped** (not +rendered) — this is a security choice; use markdown syntax instead of inline HTML. | Feature | Syntax | Notes | |---------|--------|-------| -| Headings | `# H1` through `### H3` | Rendered with `.prose` styling | +| Headings | `# H1` through `### H3` | Rendered with `.prose` styling; each heading gets an anchor `id` for deep-linking (e.g. `/pages/about#getting-started`) | | Bold / Italic | `**bold**`, `*italic*` | Standard Markdown | | Links | `[text](url)` | Relative paths supported | -| Unordered lists | `- item` or `* item` | Nested lists supported (3 levels) | -| Ordered lists | `1. item` | Nested lists supported (3 levels) | -| Tables | Pipe-delimited (`\| Header \|`) | Auto-generated ``/`` | -| Fenced code blocks | ` ``` ` with optional language | Syntax highlighting via `codehilite` extension | +| Unordered lists | `- item` or `* item` | Nested lists supported | +| Ordered lists | `1. item` | Nested lists supported | +| Tables | Pipe-delimited (`\| Header \|`) | GFM tables (thead/tbody) | +| Fenced code blocks | ` ``` ` with optional language | Rendered as `
` |
 | Inline code | `` `code` `` | Styled with monospace font |
 | Blockquotes | `> quote` | Left border styling |
 | Images | `![alt](/media/image.png)` | Use absolute paths to `/media/` |
-| Table of contents | `[TOC]` marker | Auto-generated from headings |
+| Task lists | `- [ ] item` / `- [x] item` | GFM task lists |
+| Strikethrough | `~~text~~` | GFM |
 
 ## Docker Configuration
 
diff --git a/docs/i18n.md b/docs/i18n.md
index 6618653..ca834e9 100644
--- a/docs/i18n.md
+++ b/docs/i18n.md
@@ -499,5 +499,5 @@ User profile page (OIDC authenticated users):
 
 If you're unsure about the context of a translation key, check:
 1. The "Context" column in this reference
-2. The JavaScript files in `/src/meshcore_hub/web/static/js/spa/pages/`
+2. The JavaScript files in `/src/meshcore_hub/web/static/js/spa-react/pages/`
 3. Grep for the key: `grep -r "t('section.key')" src/`
diff --git a/docs/upgrading.md b/docs/upgrading.md
index bbdab6c..177a2b0 100644
--- a/docs/upgrading.md
+++ b/docs/upgrading.md
@@ -2,6 +2,21 @@
 
 This guide covers upgrading from a previous MeshCore Hub release to the current version. Check the relevant version section below before upgrading.
 
+## v0.17.0
+
+### React Web UI
+
+The web dashboard frontend has been rewritten from lit-html ES modules to **React 19 + TypeScript + Vite**. The entire UI — navbar, footer, banners, theme toggle, and all pages — is now rendered by React. The Jinja2 shell (`spa.html`) is now a thin bootstrap: it renders only SEO `` meta, `window.__APP_CONFIG__`, font preloads, and the early theme-init script.
+
+**No configuration changes, no database migration, no `.env` changes required.** All existing env vars (`FEATURE_*`, `WEB_*`, `SYSTEM_ANNOUNCEMENT`, etc.) work identically. The upgrade is purely a frontend swap — the API, collector, and all backend behaviour are unchanged.
+
+What changed alongside the rewrite:
+
+- The `markdown` Python dependency was removed — announcements and custom pages now ship raw markdown to the client, rendered by `react-markdown`.
+- Vendor JS libraries (Chart.js, Leaflet, react-qr-code) are bundled by Vite into `static/dist/`; only fonts remain vendored in `static/vendor/`.
+- The old `src/meshcore_hub/web/static/js/spa/` directory has been removed. The Vite build (`npm run build`) is required — there is no fallback bundle.
+- Custom CSS overrides and themes are unaffected (Tailwind/DaisyUI unchanged; `app.css` still loaded).
+
 ## v0.16.0
 
 ### Route Health Monitoring
diff --git a/e2e/content/pages/about.md b/e2e/content/pages/about.md
new file mode 100644
index 0000000..7d4d5b8
--- /dev/null
+++ b/e2e/content/pages/about.md
@@ -0,0 +1,17 @@
+---
+title: About
+slug: about
+menu_order: 10
+---
+
+# About the E2E Network
+
+This page is **rendered from markdown** served by the Playwright test stack.
+
+- Deterministic content mounted via `CONTENT_HOME`
+- Sourced from `e2e/content/pages/about.md`
+- Fetched by the SPA from `/spa/pages/about`
+
+## Getting Started
+
+Some introductory detail used as a deep-link target for the heading-anchor tests.
diff --git a/e2e/docker-compose.test.yml b/e2e/docker-compose.test.yml
new file mode 100644
index 0000000..35fe72c
--- /dev/null
+++ b/e2e/docker-compose.test.yml
@@ -0,0 +1,247 @@
+# MeshCore Hub - Playwright End-to-End Test Stack
+#
+# Self-contained, throwaway stack for the Playwright suite in this directory.
+# Runs its OWN ephemeral Postgres (schema created by `migrate` via Alembic) and
+# NEVER touches the local development database: distinct project name, distinct
+# named volumes, no host port for Postgres, and no ${VAR} interpolation (so the
+# dev .env is never consulted).
+#
+# Usage (from the repo root):
+#   make e2e-build   # docker compose -f e2e/docker-compose.test.yml build
+#   make e2e-up      # ... up -d
+#   make e2e-test    # npm run test:e2e  (seeds data, then runs Playwright)
+#   make e2e-down    # ... down -v  (destroys the throwaway database)
+
+name: meshcore-e2e
+
+services:
+  # ==========================================================================
+  # PostgreSQL - ephemeral database for the e2e stack (not published to host)
+  # ==========================================================================
+  postgres:
+    image: postgres:17-alpine
+    container_name: meshcore-test-postgres
+    environment:
+      - POSTGRES_USER=meshcorehub
+      - POSTGRES_PASSWORD=e2epassword
+      - POSTGRES_DB=meshcorehub
+    volumes:
+      - test_pg_data:/var/lib/postgresql/data
+    healthcheck:
+      test: ["CMD-SHELL", "pg_isready -U meshcorehub -d meshcorehub"]
+      interval: 5s
+      timeout: 5s
+      retries: 10
+      start_period: 5s
+
+  # ==========================================================================
+  # Database Migrations - create the `meshcorehub` schema + tables (Alembic)
+  # ==========================================================================
+  migrate:
+    build:
+      context: ..
+      dockerfile: Dockerfile
+    container_name: meshcore-test-migrate
+    restart: "no"
+    depends_on:
+      postgres:
+        condition: service_healthy
+    volumes:
+      - test_data:/data
+    environment:
+      - DATA_HOME=/data
+      - DATABASE_BACKEND=postgres
+      - DATABASE_HOST=postgres
+      - DATABASE_PORT=5432
+      - DATABASE_NAME=meshcorehub
+      - DATABASE_SCHEMA=meshcorehub
+      - DATABASE_USER=meshcorehub
+      - DATABASE_PASSWORD=e2epassword
+    command: ["db", "upgrade"]
+
+  # ==========================================================================
+  # MQTT Broker
+  # ==========================================================================
+  mqtt:
+    image: ghcr.io/ipnet-mesh/meshcore-mqtt-broker:latest
+    container_name: meshcore-test-mqtt
+    ports:
+      - "11883:1883"
+    volumes:
+      - test_mqtt_data:/data
+    environment:
+      - MQTT_WS_PORT=1883
+      - MQTT_HOST=0.0.0.0
+      - AUTH_EXPECTED_AUDIENCE=mqtt.localhost
+      - SUBSCRIBER_MAX_CONNECTIONS_DEFAULT=5
+      - SUBSCRIBER_1=test-admin:test-password:1
+      - ABUSE_ENFORCEMENT_ENABLED=false
+      - ABUSE_DUPLICATE_WINDOW_SIZE=100
+      - ABUSE_DUPLICATE_WINDOW_MS=300000
+      - ABUSE_DUPLICATE_THRESHOLD=10
+      - ABUSE_MAX_DUPLICATES_PER_PACKET=5
+      - ABUSE_DUPLICATE_RATE_THRESHOLD=0.3
+      - ABUSE_DUPLICATE_RATE_WINDOW_MS=300000
+      - ABUSE_BUCKET_CAPACITY=20
+      - ABUSE_BUCKET_REFILL_RATE=3
+      - ABUSE_MAX_PACKET_SIZE=255
+      - ABUSE_MAX_TOPICS_PER_DAY=3
+      - ABUSE_ANOMALY_THRESHOLD=10
+      - ABUSE_MAX_IATA_CHANGES_24H=3
+      - ABUSE_TOPIC_HISTORY_SIZE=50
+      - ABUSE_TOPIC_HISTORY_WINDOW_MS=86400000
+      - ABUSE_PERSISTENCE_PATH=/data/abuse-detection.db
+      - ABUSE_PERSISTENCE_INTERVAL_MS=300000
+    healthcheck:
+      test: ["CMD", "node", "-e", "const net=require('net');const s=net.createConnection(1883,'127.0.0.1',()=>{s.end();process.exit(0)});s.on('error',()=>process.exit(1));setTimeout(()=>process.exit(1),3000)"]
+      interval: 5s
+      timeout: 5s
+      retries: 3
+      start_period: 5s
+
+  # ==========================================================================
+  # Collector - mounts the deterministic e2e seed script (run via `make e2e-seed`
+  # / the Playwright global setup: `exec -T collector python /seed_data.py`)
+  # ==========================================================================
+  collector:
+    build:
+      context: ..
+      dockerfile: Dockerfile
+    container_name: meshcore-test-collector
+    depends_on:
+      migrate:
+        condition: service_completed_successfully
+      mqtt:
+        condition: service_healthy
+    volumes:
+      - test_data:/data
+      - ./seed_data.py:/seed_data.py:ro
+    environment:
+      - LOG_LEVEL=INFO
+      - MQTT_HOST=mqtt
+      - MQTT_PORT=1883
+      - MQTT_PREFIX=test
+      - MQTT_TRANSPORT=websockets
+      - MQTT_WS_PATH=/
+      - MQTT_USERNAME=test-admin
+      - MQTT_PASSWORD=test-password
+      - DATA_HOME=/data
+      - DATABASE_BACKEND=postgres
+      - DATABASE_HOST=postgres
+      - DATABASE_PORT=5432
+      - DATABASE_NAME=meshcorehub
+      - DATABASE_SCHEMA=meshcorehub
+      - DATABASE_USER=meshcorehub
+      - DATABASE_PASSWORD=e2epassword
+    command: ["collector"]
+    healthcheck:
+      test: ["CMD", "pgrep", "-f", "meshcore-hub"]
+      interval: 5s
+      timeout: 5s
+      retries: 3
+      start_period: 10s
+
+  # ==========================================================================
+  # API Server
+  # ==========================================================================
+  api:
+    build:
+      context: ..
+      dockerfile: Dockerfile
+    container_name: meshcore-test-api
+    depends_on:
+      migrate:
+        condition: service_completed_successfully
+      mqtt:
+        condition: service_healthy
+    ports:
+      - "18000:8000"
+    volumes:
+      - test_data:/data
+    environment:
+      - LOG_LEVEL=INFO
+      - MQTT_HOST=mqtt
+      - MQTT_PORT=1883
+      - MQTT_PREFIX=test
+      - MQTT_TRANSPORT=websockets
+      - MQTT_WS_PATH=/
+      - MQTT_USERNAME=test-admin
+      - MQTT_PASSWORD=test-password
+      - DATA_HOME=/data
+      - DATABASE_BACKEND=postgres
+      - DATABASE_HOST=postgres
+      - DATABASE_PORT=5432
+      - DATABASE_NAME=meshcorehub
+      - DATABASE_SCHEMA=meshcorehub
+      - DATABASE_USER=meshcorehub
+      - DATABASE_PASSWORD=e2epassword
+      - API_HOST=0.0.0.0
+      - API_PORT=8000
+      - API_READ_KEY=test-read-key
+      - API_ADMIN_KEY=test-admin-key
+    command: ["api"]
+    healthcheck:
+      test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
+      interval: 5s
+      timeout: 5s
+      retries: 6
+      start_period: 10s
+
+  # ==========================================================================
+  # Web Dashboard (serves the built SPA + proxies /api/* to the API)
+  #   - OIDC enabled with a known session secret so Playwright can forge the
+  #     signed `meshcore-session` cookie (see e2e/mint_session.py). No real IdP
+  #     is required; discovery failure only logs a warning.
+  #   - WEB_AUTO_REFRESH_SECONDS=2 makes list polling fast enough to assert.
+  #   - CONTENT_HOME provides the custom markdown page (e2e/content/pages).
+  # ==========================================================================
+  web:
+    build:
+      context: ..
+      dockerfile: Dockerfile
+    container_name: meshcore-test-web
+    depends_on:
+      api:
+        condition: service_healthy
+    ports:
+      - "18080:8080"
+    volumes:
+      - ./content:/content:ro
+    environment:
+      - LOG_LEVEL=INFO
+      - API_BASE_URL=http://api:8000
+      # Admin key: the web proxy uses this as its Bearer token to the API,
+      # and admin-only writes (routes/channels) require it at the API layer.
+      # Mirrors the root compose (API_KEY=${API_ADMIN_KEY:-${API_READ_KEY}}).
+      - API_KEY=test-admin-key
+      - WEB_HOST=0.0.0.0
+      - WEB_PORT=8080
+      - NETWORK_NAME=Test Network
+      - SYSTEM_ANNOUNCEMENT=**Outage** window scheduled
+      - NETWORK_ANNOUNCEMENT=**Maintenance** window tonight
+      - WEB_LOCALE=en
+      - WEB_THEME=dark
+      - WEB_AUTO_REFRESH_SECONDS=2
+      - CONTENT_HOME=/content
+      - OIDC_ENABLED=true
+      - OIDC_CLIENT_ID=e2e-client
+      - OIDC_CLIENT_SECRET=e2e-secret
+      - OIDC_DISCOVERY_URL=https://idp.invalid/.well-known/openid-configuration
+      - OIDC_REDIRECT_URI=http://localhost:18080/auth/callback
+      - OIDC_SESSION_SECRET=test-session-secret
+      - OIDC_COOKIE_SECURE=false
+    command: ["web"]
+    healthcheck:
+      test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080/health')"]
+      interval: 5s
+      timeout: 5s
+      retries: 6
+      start_period: 10s
+
+volumes:
+  test_pg_data:
+    name: meshcore_test_pg_data
+  test_data:
+    name: meshcore_test_data
+  test_mqtt_data:
+    name: meshcore_test_mqtt_data
diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts
new file mode 100644
index 0000000..649017b
--- /dev/null
+++ b/e2e/global-setup.ts
@@ -0,0 +1,157 @@
+import { execFile } from "node:child_process";
+import fs from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { promisify } from "node:util";
+import { chromium } from "@playwright/test";
+
+const execFileAsync = promisify(execFile);
+
+const HERE = path.dirname(fileURLToPath(import.meta.url));
+const ROOT = path.resolve(HERE, "..");
+const COMPOSE_FILE = path.join(HERE, "docker-compose.test.yml");
+const BASE_URL = process.env.E2E_BASE_URL ?? "http://localhost:18080";
+const SESSION_SECRET = process.env.E2E_SESSION_SECRET ?? "test-session-secret";
+const PYTHON = process.env.E2E_PYTHON ?? path.join(ROOT, ".venv", "bin", "python");
+const AUTH_DIR = path.join(HERE, ".auth");
+
+const READY_TIMEOUT_MS = 120_000;
+const DATA_TIMEOUT_MS = 60_000;
+
+async function seedDatabase(): Promise {
+  try {
+    await execFileAsync(
+      "docker",
+      [
+        "compose",
+        "-f",
+        COMPOSE_FILE,
+        "exec",
+        "-T",
+        "collector",
+        "python",
+        "/seed_data.py",
+      ],
+      { cwd: ROOT },
+    );
+  } catch (error) {
+    const detail = error instanceof Error ? error.message : String(error);
+    throw new Error(
+      "Failed to seed the e2e database. Is the stack running? Start it with " +
+        "`make e2e-up` (or `docker compose -f e2e/docker-compose.test.yml " +
+        "up -d`).\n" +
+        detail,
+    );
+  }
+}
+
+async function poll(
+  url: string,
+  predicate: (body: unknown) => boolean,
+  timeoutMs: number,
+  description: string,
+): Promise {
+  const deadline = Date.now() + timeoutMs;
+  let lastError = "";
+  while (Date.now() < deadline) {
+    try {
+      const response = await fetch(url);
+      if (response.ok) {
+        const body = (await response.json()) as unknown;
+        if (predicate(body)) {
+          return;
+        }
+        lastError = `unexpected response body: ${JSON.stringify(body)}`;
+      } else {
+        lastError = `HTTP ${response.status}`;
+      }
+    } catch (error) {
+      lastError = error instanceof Error ? error.message : String(error);
+    }
+    await new Promise((resolve) => setTimeout(resolve, 2000));
+  }
+  throw new Error(
+    `Timed out waiting for ${description} at ${url} (${lastError}). ` +
+      "Is the e2e stack running? Start it with `make e2e-up`.",
+  );
+}
+
+async function waitForStack(): Promise {
+  await poll(
+    `${BASE_URL}/health/ready`,
+    (body) => (body as { status?: string }).status === "ready",
+    READY_TIMEOUT_MS,
+    "the web service to become ready",
+  );
+  await poll(
+    `${BASE_URL}/api/v1/nodes?limit=1`,
+    (body) =>
+      typeof (body as { total?: number }).total === "number" &&
+      (body as { total: number }).total > 0,
+    DATA_TIMEOUT_MS,
+    "seeded data to be visible via the API",
+  );
+}
+
+async function mintSessionCookie(
+  sub: string,
+  name: string,
+  email: string,
+  roles: string,
+): Promise {
+  const { stdout } = await execFileAsync(PYTHON, [
+    path.join(HERE, "mint_session.py"),
+    SESSION_SECRET,
+    sub,
+    name,
+    email,
+    roles,
+  ]);
+  const cookie = stdout.trim();
+  if (!cookie) {
+    throw new Error("mint_session.py produced an empty cookie");
+  }
+  return cookie;
+}
+
+async function writeStorageState(cookie: string, file: string): Promise {
+  const browser = await chromium.launch();
+  try {
+    const context = await browser.newContext();
+    await context.addCookies([
+      {
+        name: "meshcore-session",
+        value: cookie,
+        domain: new URL(BASE_URL).hostname,
+        path: "/",
+        httpOnly: true,
+        secure: false,
+        sameSite: "Lax",
+      },
+    ]);
+    await context.storageState({ path: file });
+  } finally {
+    await browser.close();
+  }
+}
+
+export default async function globalSetup(): Promise {
+  await seedDatabase();
+  await waitForStack();
+
+  fs.mkdirSync(AUTH_DIR, { recursive: true });
+  const adminCookie = await mintSessionCookie(
+    "pw-admin",
+    "PW Admin",
+    "pw-admin@example.com",
+    "admin,member",
+  );
+  const memberCookie = await mintSessionCookie(
+    "pw-member",
+    "PW Member",
+    "pw-member@example.com",
+    "member",
+  );
+  await writeStorageState(adminCookie, path.join(AUTH_DIR, "admin.json"));
+  await writeStorageState(memberCookie, path.join(AUTH_DIR, "member.json"));
+}
diff --git a/e2e/mint_session.py b/e2e/mint_session.py
new file mode 100644
index 0000000..ba76045
--- /dev/null
+++ b/e2e/mint_session.py
@@ -0,0 +1,53 @@
+"""Mint a signed ``meshcore-session`` cookie for Playwright e2e tests.
+
+Reproduces Starlette's SessionMiddleware signing scheme (an
+``itsdangerous.TimestampSigner`` over the base64-encoded session JSON) so the
+forged cookie is accepted by the web tier exactly like a real OIDC login -
+populating ``window.__APP_CONFIG__`` (user + roles) and driving the API proxy's
+``X-User-Id`` / ``X-User-Roles`` injection. No IdP round-trip is performed.
+
+The secret must match the stack's ``OIDC_SESSION_SECRET``
+(``test-session-secret`` in ``e2e/docker-compose.test.yml``).
+
+Usage:
+    python e2e/mint_session.py     
+
+Prints the cookie value to stdout.
+"""
+
+from __future__ import annotations
+
+import base64
+import json
+import sys
+
+import itsdangerous
+
+
+def mint(secret: str, sub: str, name: str, email: str, roles_csv: str) -> str:
+    """Return a signed session-cookie value for the given identity/roles."""
+    session = {
+        "user": {
+            "sub": sub,
+            "name": name,
+            "email": email,
+            "picture": None,
+            "roles": [r.strip() for r in roles_csv.split(",") if r.strip()],
+        }
+    }
+    data = base64.b64encode(json.dumps(session).encode("utf-8"))
+    signed = itsdangerous.TimestampSigner(secret).sign(data).decode("utf-8")
+    return str(signed)
+
+
+def main() -> None:
+    if len(sys.argv) != 6:
+        raise SystemExit(
+            "usage: mint_session.py     "
+        )
+    secret, sub, name, email, roles_csv = sys.argv[1:6]
+    print(mint(secret, sub, name, email, roles_csv))
+
+
+if __name__ == "__main__":
+    main()
diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts
new file mode 100644
index 0000000..893f1ff
--- /dev/null
+++ b/e2e/playwright.config.ts
@@ -0,0 +1,27 @@
+import { defineConfig } from "@playwright/test";
+
+const baseURL = process.env.E2E_BASE_URL ?? "http://localhost:18080";
+
+export default defineConfig({
+  testDir: "./tests",
+  outputDir: "./test-results",
+  globalSetup: "./global-setup.ts",
+  // Single throwaway backend: run serially so mutating specs (routes, profile)
+  // cannot race each other.
+  fullyParallel: false,
+  workers: 1,
+  retries: 0,
+  reporter: [["list"], ["html", { open: "never", outputFolder: "./playwright-report" }]],
+  use: {
+    baseURL,
+    headless: true,
+    viewport: { width: 1280, height: 800 },
+    actionTimeout: 15_000,
+    navigationTimeout: 30_000,
+    trace: "retain-on-failure",
+    screenshot: "only-on-failure",
+  },
+  expect: {
+    timeout: 15_000,
+  },
+});
diff --git a/e2e/seed_data.py b/e2e/seed_data.py
new file mode 100644
index 0000000..3ec4ed2
--- /dev/null
+++ b/e2e/seed_data.py
@@ -0,0 +1,491 @@
+"""Deterministic seed data for the Playwright end-to-end test stack.
+
+Runs inside the e2e collector container (which has the app and the Postgres
+driver installed) against the throwaway e2e database:
+
+    docker compose -f e2e/docker-compose.test.yml exec -T collector \
+        python /seed_data.py
+
+Idempotent: clears previously seeded rows and recreates them with fixed public
+keys and recent timestamps, so every run yields the same dataset. The e2e
+stack uses its own ephemeral Postgres instance - this never touches the local
+development database.
+"""
+
+from __future__ import annotations
+
+import time
+from datetime import datetime, timedelta, timezone
+
+from sqlalchemy import delete
+from sqlalchemy.exc import OperationalError
+from sqlalchemy.orm import Session
+
+from meshcore_hub.common.config import get_common_settings
+from meshcore_hub.common.database import (
+    create_database_engine,
+    create_session_factory,
+)
+from meshcore_hub.common.models import (
+    Advertisement,
+    Channel,
+    EventObserver,
+    Message,
+    Node,
+    NodeTag,
+    PacketPathHop,
+    RawPacket,
+    Route,
+    RouteNode,
+    RouteObserver,
+    RouteRecentMatch,
+    RouteResult,
+    RouteResultHistory,
+    UserProfile,
+    UserProfileNode,
+)
+
+NOW = datetime.now(timezone.utc)
+
+ALPHA = ("a1fa" + "0" * 64)[:64]
+BRAVO = ("b2b0" + "0" * 64)[:64]
+CHARLIE = ("c3c0" + "0" * 64)[:64]
+DELTA = ("d4d0" + "0" * 64)[:64]
+NORTH_1 = ("aa01" + "0" * 64)[:64]
+NORTH_2 = ("aa02" + "0" * 64)[:64]
+SOUTH_1 = ("bb01" + "0" * 64)[:64]
+SOUTH_2 = ("bb02" + "0" * 64)[:64]
+
+PATH_HOPS = [ALPHA[:4].upper(), BRAVO[:4].upper(), CHARLIE[:4].upper()]
+
+
+def _hash(seed: str) -> str:
+    return (seed + "0" * 32)[:32]
+
+
+def _event_hash(counter: int) -> str:
+    return f"{counter:032x}"
+
+
+def _ago(minutes: float = 0.0, hours: float = 0.0, days: float = 0.0) -> datetime:
+    return NOW - timedelta(minutes=minutes, hours=hours, days=days)
+
+
+def clear(session: Session) -> None:
+    for model in (
+        RouteRecentMatch,
+        RouteResultHistory,
+        RouteResult,
+        RouteObserver,
+        RouteNode,
+        Route,
+        PacketPathHop,
+        RawPacket,
+        EventObserver,
+        Advertisement,
+        Message,
+        UserProfileNode,
+        UserProfile,
+        NodeTag,
+        Node,
+        Channel,
+    ):
+        session.execute(delete(model))
+
+
+def seed_nodes(session: Session) -> dict[str, Node]:
+    content_specs = [
+        (ALPHA, "Alpha Node", "chat", 51.5074, -0.1278),
+        (BRAVO, "Bravo Node", "repeater", 52.4862, -1.8904),
+        (CHARLIE, "Charlie Node", "room", 53.4808, -2.2426),
+        (DELTA, "Delta Node", "chat", None, None),
+    ]
+    observer_specs = [
+        (NORTH_1, "North Observer 1", "North", 51.6, -0.2),
+        (NORTH_2, "North Observer 2", "North", 51.7, -0.3),
+        (SOUTH_1, "South Observer 1", "South", 52.5, -1.9),
+        (SOUTH_2, "South Observer 2", "South", 52.6, -2.0),
+    ]
+
+    nodes: dict[str, Node] = {}
+    for i, (pk, name, adv_type, lat, lon) in enumerate(content_specs):
+        node = Node(
+            public_key=pk,
+            name=name,
+            adv_type=adv_type,
+            lat=lat,
+            lon=lon,
+            first_seen=_ago(days=30),
+            last_seen=_ago(minutes=i + 1),
+        )
+        session.add(node)
+        nodes[pk] = node
+
+    for i, (pk, name, _area, lat, lon) in enumerate(observer_specs):
+        node = Node(
+            public_key=pk,
+            name=name,
+            adv_type="repeater",
+            lat=lat,
+            lon=lon,
+            is_observer=True,
+            first_seen=_ago(days=30),
+            last_seen=_ago(minutes=i + 1),
+        )
+        session.add(node)
+        nodes[pk] = node
+
+    session.flush()
+    for pk, _name, area, _lat, _lon in observer_specs:
+        session.add(NodeTag(node_id=nodes[pk].id, key="area", value=area))
+    for pk in nodes:
+        session.add(NodeTag(node_id=nodes[pk].id, key="name", value=nodes[pk].name))
+    session.flush()
+    return nodes
+
+
+def seed_advertisements(
+    session: Session, nodes: dict[str, Node]
+) -> dict[str, tuple[str, float]]:
+    specs = [
+        (ALPHA, NORTH_1, "ad01", "flood", 30.0),
+        (BRAVO, SOUTH_1, "ad02", "flood", 45.0),
+        (CHARLIE, NORTH_2, "ad03", "direct", 60.0),
+        (DELTA, SOUTH_2, "ad04", "transport_flood", 90.0),
+        (ALPHA, SOUTH_1, "ad05", "flood", 120.0),
+        (BRAVO, NORTH_1, "ad06", "flood", 150.0),
+    ]
+    events: dict[str, tuple[str, float]] = {}
+    for i, (node_pk, observer_pk, seed, route_type, minutes) in enumerate(specs):
+        packet_hash = _hash(seed)
+        event_hash = _event_hash(i + 1)
+        events[packet_hash] = (event_hash, minutes)
+        node = nodes[node_pk]
+        received_at = _ago(minutes=minutes)
+        session.add(
+            Advertisement(
+                observer_node_id=nodes[observer_pk].id,
+                node_id=node.id,
+                public_key=node_pk,
+                name=node.name,
+                adv_type=node.adv_type,
+                received_at=received_at,
+                event_hash=event_hash,
+                packet_hash=packet_hash,
+                route_type=route_type,
+                advert_timestamp=received_at,
+            )
+        )
+        session.add(
+            EventObserver(
+                event_type="advertisement",
+                event_hash=event_hash,
+                observer_node_id=nodes[observer_pk].id,
+                snr=7.5,
+                path_len=2,
+                observed_at=received_at,
+            )
+        )
+    session.flush()
+    return events
+
+
+def seed_messages(
+    session: Session, nodes: dict[str, Node], custom_channel_idx: int
+) -> dict[str, tuple[str, float]]:
+    specs = [
+        ("channel", NORTH_1, "ce01", 10.0, "Hello from the e2e mesh", 17, None),
+        ("channel", SOUTH_1, "ce02", 20.0, "Channel check from the south", 17, None),
+        (
+            "channel",
+            NORTH_2,
+            "ce03",
+            25.0,
+            "Ops channel traffic",
+            custom_channel_idx,
+            None,
+        ),
+        (
+            "contact",
+            SOUTH_2,
+            "ce04",
+            15.0,
+            "Direct hello over the mesh",
+            None,
+            ALPHA[:12],
+        ),
+    ]
+    events: dict[str, tuple[str, float]] = {}
+    for i, (mtype, observer_pk, seed, minutes, text, channel_idx, prefix) in enumerate(
+        specs
+    ):
+        packet_hash = _hash(seed)
+        event_hash = _event_hash(100 + i)
+        events[packet_hash] = (event_hash, minutes)
+        received_at = _ago(minutes=minutes)
+        session.add(
+            Message(
+                observer_node_id=nodes[observer_pk].id,
+                message_type=mtype,
+                pubkey_prefix=prefix,
+                channel_idx=channel_idx,
+                text=text,
+                path_len=2,
+                snr=6.5,
+                received_at=received_at,
+                event_hash=event_hash,
+                packet_hash=packet_hash,
+            )
+        )
+        session.add(
+            EventObserver(
+                event_type="message",
+                event_hash=event_hash,
+                observer_node_id=nodes[observer_pk].id,
+                snr=6.5,
+                path_len=2,
+                observed_at=received_at,
+            )
+        )
+    session.flush()
+    return events
+
+
+def seed_raw_packets(
+    session: Session,
+    nodes: dict[str, Node],
+    advert_events: dict[str, tuple[str, float]],
+    message_events: dict[str, tuple[str, float]],
+    custom_channel_idx: int,
+) -> str:
+    channel_indices = {
+        _hash("ce01"): 17,
+        _hash("ce02"): 17,
+        _hash("ce03"): custom_channel_idx,
+    }
+    events = {
+        **{h: (e, m, "advertisement") for h, (e, m) in advert_events.items()},
+        **{
+            h: (e, m, "contact_msg_recv" if h == _hash("ce04") else "channel_msg_recv")
+            for h, (e, m) in message_events.items()
+        },
+    }
+
+    first_raw_packet_id = ""
+    for packet_hash, (event_hash, minutes, event_type) in sorted(events.items()):
+        for j, observer_pk in enumerate((NORTH_1, SOUTH_1)):
+            received_at = _ago(minutes=minutes) + timedelta(seconds=j * 3)
+            raw = RawPacket(
+                observer_node_id=nodes[observer_pk].id,
+                packet_hash=packet_hash,
+                event_hash=event_hash,
+                raw_hex=(packet_hash * 4)[:128],
+                packet_type=5,
+                payload_type=4 if event_type == "advertisement" else 5,
+                event_type=event_type,
+                channel_idx=channel_indices.get(packet_hash),
+                source_pubkey_prefix=ALPHA[:12],
+                route_type="flood",
+                path_len=3,
+                path_hash_bytes=2,
+                snr=8.5 - j * 2.25,
+                decoded={"e2e": True, "packet_hash": packet_hash},
+                received_at=received_at,
+            )
+            session.add(raw)
+            session.flush()
+            if not first_raw_packet_id:
+                first_raw_packet_id = raw.id
+            for position, node_hash in enumerate(PATH_HOPS):
+                session.add(
+                    PacketPathHop(
+                        raw_packet_id=raw.id,
+                        position=position,
+                        node_hash=node_hash,
+                        packet_hash=packet_hash,
+                        event_hash=event_hash,
+                        received_at=received_at,
+                        observer_node_id=nodes[observer_pk].id,
+                    )
+                )
+    session.flush()
+    return first_raw_packet_id
+
+
+def seed_channels(session: Session) -> int:
+    keys = [
+        ("E2E General", "00112233445566778899aabbccddeeff" * 2),
+        ("E2E Ops", "ffeeddccbbaa99887766554433221100" * 2),
+    ]
+    for name, key_hex in keys:
+        session.add(
+            Channel(
+                name=name,
+                key_hex=key_hex,
+                channel_hash=Channel.compute_channel_hash(key_hex),
+                visibility="community",
+                enabled=True,
+            )
+        )
+    session.flush()
+    general_hash = Channel.compute_channel_hash(keys[0][1])
+    return int(general_hash, 16)
+
+
+def seed_routes(
+    session: Session, nodes: dict[str, Node], first_raw_packet_id: str
+) -> None:
+    route = Route(
+        from_label="Alpha Site",
+        to_label="Bravo Site",
+        description="Synthetic e2e route",
+        visibility="community",
+        match_width=2,
+        window_hours=48,
+        packet_count_threshold=3,
+        clear_threshold=6,
+        max_hop_span=8,
+        enabled=True,
+        reversible=True,
+    )
+    session.add(route)
+    session.flush()
+
+    for position, pk in enumerate((ALPHA, BRAVO)):
+        session.add(
+            RouteNode(
+                route_id=route.id,
+                node_id=nodes[pk].id,
+                position=position,
+                expected_hash=pk[:4].upper(),
+            )
+        )
+    session.add(RouteObserver(route_id=route.id, node_id=nodes[NORTH_1].id))
+
+    session.add(
+        RouteResult(
+            route_id=route.id,
+            state="healthy",
+            quality="clear",
+            matched_count=7,
+            threshold=3,
+            effective_clear=6,
+            evaluated_at=_ago(minutes=5),
+            quality_avg="clear",
+        )
+    )
+
+    history = [
+        ("clear", 7),
+        ("clear", 6),
+        ("marginal", 4),
+        ("clear", 5),
+        ("marginal", 3),
+        ("failing", 1),
+        ("clear", 6),
+    ]
+    for day_offset, (quality, matched) in enumerate(history):
+        session.add(
+            RouteResultHistory(
+                route_id=route.id,
+                date=(NOW - timedelta(days=day_offset)).date(),
+                quality=quality,
+                state="unhealthy" if quality == "failing" else "healthy",
+                matched_count=matched,
+                evaluated_at=_ago(days=day_offset),
+            )
+        )
+
+    if first_raw_packet_id:
+        session.add(
+            RouteRecentMatch(
+                route_id=route.id,
+                raw_packet_id=first_raw_packet_id,
+                first_position=0,
+                last_position=1,
+            )
+        )
+    session.flush()
+
+
+def seed_profiles(session: Session, nodes: dict[str, Node]) -> None:
+    specs = [
+        (
+            "pw-admin",
+            "PW Admin",
+            "E2EADM",
+            "admin,member",
+            "Playwright admin user",
+            "https://example.com/pw-admin",
+        ),
+        (
+            "pw-member",
+            "PW Member",
+            "E2EMBR",
+            "member",
+            "Playwright member user",
+            None,
+        ),
+        ("op-north", "Op North", "OPN1", "operator,member", "North operator", None),
+        ("mem-south", "Mem South", "MEMS1", "member", "South member", None),
+    ]
+    profiles: dict[str, UserProfile] = {}
+    for user_id, name, callsign, roles, description, url in specs:
+        profile = UserProfile(
+            user_id=user_id,
+            name=name,
+            callsign=callsign,
+            roles=roles,
+            description=description,
+            url=url,
+        )
+        session.add(profile)
+        profiles[user_id] = profile
+    session.flush()
+
+    session.add(
+        UserProfileNode(
+            user_profile_id=profiles["op-north"].id, node_id=nodes[ALPHA].id
+        )
+    )
+    session.add(
+        UserProfileNode(
+            user_profile_id=profiles["mem-south"].id, node_id=nodes[BRAVO].id
+        )
+    )
+    session.flush()
+
+
+def main() -> None:
+    settings = get_common_settings()
+    engine = create_database_engine(
+        settings.effective_database_url,
+        schema=settings.effective_database_schema,
+    )
+    session_factory = create_session_factory(engine)
+
+    last_error: OperationalError | None = None
+    for _ in range(15):
+        try:
+            with session_factory() as session:
+                clear(session)
+                nodes = seed_nodes(session)
+                custom_channel_idx = seed_channels(session)
+                advert_events = seed_advertisements(session, nodes)
+                message_events = seed_messages(session, nodes, custom_channel_idx)
+                first_raw_packet_id = seed_raw_packets(
+                    session, nodes, advert_events, message_events, custom_channel_idx
+                )
+                seed_routes(session, nodes, first_raw_packet_id)
+                seed_profiles(session, nodes)
+                session.commit()
+            print("e2e seed data written")
+            return
+        except OperationalError as exc:
+            last_error = exc
+            time.sleep(2)
+    raise RuntimeError(f"database not reachable after retries: {last_error}")
+
+
+if __name__ == "__main__":
+    main()
diff --git a/e2e/tests/advertisements.spec.ts b/e2e/tests/advertisements.spec.ts
new file mode 100644
index 0000000..f802c76
--- /dev/null
+++ b/e2e/tests/advertisements.spec.ts
@@ -0,0 +1,91 @@
+import { expect, test } from "@playwright/test";
+import { countApiCalls, expectListLoaded, openFilters } from "../utils/helpers";
+
+const ALPHA_KEY = "a1fa" + "0".repeat(60);
+
+test.use({ permissions: ["clipboard-read", "clipboard-write"] });
+
+test.describe("advertisements", () => {
+  test("filter options work", async ({ page }) => {
+    await page.goto("/advertisements");
+    await expectListLoaded(page);
+    const table = page.locator("table");
+
+    await openFilters(page);
+    await page.locator('select[name="route_type"]').selectOption("all");
+    await page.getByRole("button", { name: "Filter" }).click();
+    await expect(page).toHaveURL(/route_type=all/);
+    await expect(table.getByText("Charlie Node").first()).toBeVisible();
+
+    await openFilters(page);
+    await page.locator('input[name="search"]').fill("Bravo");
+    await page.getByRole("button", { name: "Filter" }).click();
+    await expect(page).toHaveURL(/search=Bravo/);
+    // Two Bravo adverts (one per area); toHaveCount auto-waits out the refetch.
+    await expect(page.getByTestId("list-row")).toHaveCount(2);
+    await expect(table.getByText("Alpha Node")).toHaveCount(0);
+  });
+
+  test("auto-refresh works and can be paused", async ({ page }) => {
+    await page.goto("/advertisements");
+    await expectListLoaded(page);
+
+    const toggle = page.getByTestId("auto-refresh-toggle");
+    await expect(toggle).toBeChecked();
+
+    const active = await countApiCalls(page, "/api/v1/advertisements?", 5000);
+    expect(active).toBeGreaterThanOrEqual(2);
+
+    await toggle.click();
+    await expect(toggle).not.toBeChecked();
+    const paused = await countApiCalls(page, "/api/v1/advertisements?", 4500);
+    expect(paused).toBe(0);
+  });
+
+  test("table row actions work", async ({ page }) => {
+    await page.goto("/advertisements");
+    await expectListLoaded(page);
+    const table = page.locator("table");
+
+    await table.getByRole("link", { name: "Alpha Node" }).first().click();
+    await expect(page).toHaveURL(new RegExp(`/nodes/${ALPHA_KEY}`));
+    await page.goBack();
+    await expectListLoaded(page);
+
+    // Click a plain-text cell (Time): node links and copyable keys
+    // stopPropagation / have their own handlers.
+    await page.getByTestId("list-row").first().locator("td").nth(3).click();
+    await expect(page).toHaveURL(/\/packets\/hash\//);
+
+    await page.goto("/advertisements");
+    await expectListLoaded(page);
+    const copyable = table.locator('code[title="Click to copy"]').first();
+    await copyable.click();
+    await expect(page.getByText("Copied!").first()).toBeVisible();
+  });
+
+  test("observer toggles filter the list", async ({ page }) => {
+    await page.goto("/advertisements");
+    await expectListLoaded(page);
+
+    const north = page.locator('[data-testid="observer-area"][data-area="North"]');
+    const south = page.locator('[data-testid="observer-area"][data-area="South"]');
+    await expect(north.first()).toBeVisible();
+    await expect(south.first()).toBeVisible();
+    await expect(page.getByTestId("list-row")).toHaveCount(5);
+
+    await north.first().click();
+    await expect(north.first()).toHaveClass(/badge-ghost/);
+    await expect(page.getByTestId("list-row")).toHaveCount(3);
+
+    await north.first().click();
+    await expect(north.first()).toHaveClass(/badge-primary/);
+    await expect(page.getByTestId("list-row")).toHaveCount(5);
+
+    await south.first().click();
+    await expect(page.getByTestId("list-row")).toHaveCount(2);
+    await north.first().click();
+    await expect(north.first()).toHaveClass(/badge-primary/);
+    await expect(page.getByTestId("list-row")).toHaveCount(2);
+  });
+});
diff --git a/e2e/tests/announcements.spec.ts b/e2e/tests/announcements.spec.ts
new file mode 100644
index 0000000..b2fe72b
--- /dev/null
+++ b/e2e/tests/announcements.spec.ts
@@ -0,0 +1,31 @@
+import { expect, test } from "@playwright/test";
+
+test.describe("announcements", () => {
+  test("system announcement renders markdown bold", async ({ page }) => {
+    await page.goto("/");
+
+    // SYSTEM_ANNOUNCEMENT=**Outage** window scheduled is shipped as raw
+    // markdown in __APP_CONFIG__ and rendered client-side by .
+    // **Outage** must become a  element end-to-end.
+    const banner = page.locator("#system-banner");
+    await expect(banner).toBeVisible();
+    await expect(banner.locator("strong")).toHaveText("Outage");
+    await expect(banner).toContainText("window scheduled");
+  });
+
+  test("network announcement dismiss persists across reload", async ({ page }) => {
+    await page.goto("/");
+
+    const banner = page.locator("#flash-banner");
+    await expect(banner).toBeVisible();
+    await expect(banner.locator("strong")).toHaveText("Maintenance");
+
+    await page.getByRole("button", { name: "Dismiss" }).click();
+    await expect(banner).not.toBeVisible();
+
+    // The dismiss flag is persisted in sessionStorage — reloading must NOT
+    // bring the banner back.
+    await page.reload();
+    await expect(banner).not.toBeVisible();
+  });
+});
diff --git a/e2e/tests/custom-page.spec.ts b/e2e/tests/custom-page.spec.ts
new file mode 100644
index 0000000..8a4d61d
--- /dev/null
+++ b/e2e/tests/custom-page.spec.ts
@@ -0,0 +1,55 @@
+import { expect, test } from "@playwright/test";
+
+test.describe("custom pages", () => {
+  test("markdown content is rendered", async ({ page }) => {
+    await page.goto("/pages/about");
+
+    const prose = page.locator(".card-body .prose");
+    await expect(prose).toBeVisible();
+    await expect(
+      prose.getByRole("heading", { name: "About the E2E Network" }),
+    ).toBeVisible();
+    await expect(prose.getByText("rendered from markdown")).toBeVisible();
+    await expect(
+      prose.getByText("Fetched by the SPA from /spa/pages/about"),
+    ).toBeVisible();
+  });
+
+  test("custom page appears in the navigation", async ({ page }) => {
+    await page.goto("/");
+
+    const link = page.locator('[data-testid="nav-link"][data-nav-href="/pages/about"]');
+    await expect(link.first()).toBeVisible();
+    await link.first().click();
+    await expect(page).toHaveURL(/\/pages\/about/);
+    await expect(page.locator(".card-body .prose")).toBeVisible();
+  });
+
+  test("heading anchor updates the URL hash on click", async ({ page }) => {
+    await page.goto("/pages/about");
+
+    // rehype-slug assigns the id; rehype-autolink-headings (behavior: "wrap")
+    // wraps the heading text in an .
+    const anchor = page.locator("h2#getting-started a");
+    await expect(anchor).toHaveAttribute("href", "#getting-started");
+    await anchor.click();
+    await expect(page).toHaveURL(/#getting-started$/);
+  });
+
+  test("direct hash navigation scrolls the heading into view", async ({ page }) => {
+    // Exercises the async-load scroll effect in CustomPage.tsx: the heading is
+    // absent until /spa/pages/about resolves, then scrollIntoView fires.
+    await page.goto("/pages/about#getting-started");
+
+    const heading = page.getByRole("heading", { name: "Getting Started" });
+    await expect(heading).toBeInViewport();
+  });
+
+  test("unknown slug shows a not-found error", async ({ page }) => {
+    await page.goto("/pages/does-not-exist");
+
+    const alert = page.locator('[role="alert"]');
+    await expect(alert).toBeVisible();
+    await expect(alert).toContainText(/not found/i);
+  });
+});
diff --git a/e2e/tests/dashboard.spec.ts b/e2e/tests/dashboard.spec.ts
new file mode 100644
index 0000000..80894cb
--- /dev/null
+++ b/e2e/tests/dashboard.spec.ts
@@ -0,0 +1,35 @@
+import { expect, test } from "@playwright/test";
+
+test.describe("dashboard", () => {
+  test("all widgets render", async ({ page }) => {
+    await page.goto("/dashboard");
+    const main = page.locator("main");
+
+    await expect(main.getByRole("heading", { name: "Dashboard" })).toBeVisible();
+
+    for (const title of [
+      "Nodes",
+      "Adverts",
+      "Messages",
+      "Packets",
+      "Packet Types",
+      "Path Bytes",
+      "Route Health",
+      "Route Trends",
+      "Recent Adverts",
+      "Recent Channel Messages",
+    ]) {
+      await expect(main.getByText(title, { exact: true }).first()).toBeVisible();
+    }
+
+    expect(await page.locator("canvas").count()).toBeGreaterThanOrEqual(5);
+
+    await expect(main.getByText("Alpha Site")).toBeVisible();
+    await expect(main.getByText("Bravo Site").first()).toBeVisible();
+
+    await expect(main.getByText("Alpha Node").first()).toBeVisible();
+    await expect(
+      main.locator('a[href^="/nodes/"]').first(),
+    ).toBeVisible();
+  });
+});
diff --git a/e2e/tests/global.spec.ts b/e2e/tests/global.spec.ts
new file mode 100644
index 0000000..36d9399
--- /dev/null
+++ b/e2e/tests/global.spec.ts
@@ -0,0 +1,79 @@
+import { expect, test } from "@playwright/test";
+
+const NAV_TARGETS = [
+  "/",
+  "/dashboard",
+  "/nodes",
+  "/advertisements",
+  "/routes",
+  "/channels",
+  "/messages",
+  "/packets",
+  "/map",
+  "/members",
+  "/pages/about",
+];
+
+test.describe("global", () => {
+  test("all navigation links work", async ({ page }) => {
+    await page.goto("/");
+    // Scope to the desktop menu: MobileNav renders the same links (hidden >= lg).
+    const navLinks = page.locator(".navbar-center [data-testid='nav-link']");
+    await expect(navLinks.first()).toBeVisible();
+
+    await expect(navLinks).toHaveCount(NAV_TARGETS.length);
+    for (const href of NAV_TARGETS) {
+      await expect(
+        page.locator(`.navbar-center [data-nav-href="${href}"]`),
+      ).toHaveCount(1);
+    }
+
+    for (const href of NAV_TARGETS) {
+      const link = page.locator(`.navbar-center [data-nav-href="${href}"]`);
+      await link.click();
+      await expect(page).toHaveURL(new RegExp(href === "/" ? "/$" : href));
+      await expect(link).toHaveClass(/active/);
+    }
+  });
+
+  test("footer renders network name and attribution", async ({ page }) => {
+    await page.goto("/");
+
+    const footer = page.locator("footer");
+    await expect(footer).toBeVisible();
+    // Network name comes from __APP_CONFIG__ (NETWORK_NAME=Test Network).
+    await expect(footer.getByText("Test Network")).toBeVisible();
+    // Hub attribution link.
+    await expect(
+      footer.getByRole("link", { name: "MeshCore Hub" }),
+    ).toBeVisible();
+    // Tagline (i18n key footer.tagline).
+    await expect(
+      footer.getByText("Off-Grid, Open-Source Encrypted Messaging"),
+    ).toBeVisible();
+  });
+
+  test("dark/light toggle works and persists", async ({ page }) => {
+    await page.goto("/");
+    // The checkbox itself is visually hidden by daisyUI's swap; click the label.
+    const toggle = page.getByTestId("theme-toggle");
+    const toggleControl = page.locator("label.swap");
+    const html = page.locator("html");
+
+    await expect(html).toHaveAttribute("data-theme", "dark");
+    await expect(toggle).not.toBeChecked();
+
+    await toggleControl.click();
+    await expect(html).toHaveAttribute("data-theme", "light");
+    await expect(toggle).toBeChecked();
+
+    await page.reload();
+    await expect(html).toHaveAttribute("data-theme", "light");
+
+    await page.locator("label.swap").click();
+    await expect(html).toHaveAttribute("data-theme", "dark");
+
+    await page.reload();
+    await expect(html).toHaveAttribute("data-theme", "dark");
+  });
+});
diff --git a/e2e/tests/home.spec.ts b/e2e/tests/home.spec.ts
new file mode 100644
index 0000000..ccd53cf
--- /dev/null
+++ b/e2e/tests/home.spec.ts
@@ -0,0 +1,50 @@
+import { expect, test } from "@playwright/test";
+
+const HERO_TARGETS = [
+  "/dashboard",
+  "/nodes",
+  "/advertisements",
+  "/routes",
+  "/channels",
+  "/messages",
+  "/packets",
+  "/map",
+  "/members",
+];
+
+test.describe("home", () => {
+  test("renders hero, stats and activity panels", async ({ page }) => {
+    await page.goto("/");
+
+    await expect(page.locator("h1.hero-title")).toHaveText("Test Network");
+    await expect(
+      page.getByText("Welcome to the Test Network mesh network dashboard."),
+    ).toBeVisible();
+
+    const stats = page.locator(".stat");
+    await expect(stats.first()).toBeVisible();
+    expect(await stats.count()).toBeGreaterThanOrEqual(4);
+    await expect(page.getByText("All discovered nodes")).toBeVisible();
+
+    await expect(
+      page.getByRole("heading", { name: "Network Activity" }),
+    ).toBeVisible();
+    expect(await page.locator("canvas").count()).toBeGreaterThanOrEqual(1);
+  });
+
+  test("hero navigation links work", async ({ page }) => {
+    await page.goto("/");
+    const cards = page.getByTestId("hero-card");
+    await expect(cards.first()).toBeVisible();
+
+    for (const href of HERO_TARGETS) {
+      const card = page.locator(
+        `[data-testid="hero-card"][data-hero-href="${href}"]`,
+      );
+      await expect(card).toBeVisible();
+      await card.click();
+      await expect(page).toHaveURL(new RegExp(href));
+      await page.goto("/");
+    }
+  });
+});
diff --git a/e2e/tests/map.spec.ts b/e2e/tests/map.spec.ts
new file mode 100644
index 0000000..98708c4
--- /dev/null
+++ b/e2e/tests/map.spec.ts
@@ -0,0 +1,43 @@
+import { expect, test } from "@playwright/test";
+import { openFilters } from "../utils/helpers";
+
+test.describe("map", () => {
+  test("renders markers and filter options work (incl. show labels)", async ({
+    page,
+  }) => {
+    await page.goto("/map");
+
+    const markers = page.locator(".map-marker");
+    await expect(markers.first()).toBeVisible();
+    await expect(markers).toHaveCount(7);
+    await expect(page.getByText("7 nodes on map")).toBeVisible();
+
+    await openFilters(page);
+
+    await page
+      .locator('select:has(option[value="repeater"])')
+      .selectOption("repeater");
+    await expect(markers).toHaveCount(5);
+    await expect(page.getByText("5 shown")).toBeVisible();
+
+    await page.getByLabel("Show Labels").check();
+    await expect(page.locator(".show-labels").first()).toBeVisible();
+    await expect(page.locator(".map-label").first()).toBeVisible();
+
+    await page.getByRole("button", { name: "Clear Filters" }).click();
+    await expect(markers).toHaveCount(7);
+    await expect(page.locator(".show-labels")).toHaveCount(0);
+    await expect(page.getByLabel("Show Labels")).not.toBeChecked();
+  });
+
+  test("marker popup links to node detail", async ({ page }) => {
+    await page.goto("/map");
+    await expect(page.locator(".map-marker").first()).toBeVisible();
+
+    await page.locator(".map-marker").first().click();
+    const popup = page.locator(".leaflet-popup");
+    await expect(popup).toBeVisible();
+    await popup.getByRole("link", { name: "View Details" }).click();
+    await expect(page).toHaveURL(/\/nodes\/[0-9a-f]{64}/);
+  });
+});
diff --git a/e2e/tests/members.spec.ts b/e2e/tests/members.spec.ts
new file mode 100644
index 0000000..304d09b
--- /dev/null
+++ b/e2e/tests/members.spec.ts
@@ -0,0 +1,50 @@
+import { expect, test } from "@playwright/test";
+import { MEMBER_STATE } from "../utils/helpers";
+
+const BRAVO_KEY = "b2b0" + "0".repeat(60);
+
+test.use({ storageState: MEMBER_STATE });
+
+test.describe("members", () => {
+  test("lists operators and members", async ({ page }) => {
+    await page.goto("/members");
+
+    // Level 2: the group headings (level 1 is the page title "Members").
+    await expect(
+      page.getByRole("heading", { name: "Operators", level: 2 }),
+    ).toBeVisible();
+    await expect(
+      page.getByRole("heading", { name: "Members", level: 2 }),
+    ).toBeVisible();
+    await expect(page.getByText("Op North")).toBeVisible();
+    await expect(page.getByText("Mem South")).toBeVisible();
+    expect(await page.getByTestId("member-card").count()).toBeGreaterThanOrEqual(4);
+  });
+
+  test("clicking a member shows the profile page", async ({ page }) => {
+    await page.goto("/members");
+
+    await page
+      .getByTestId("member-card")
+      .filter({ hasText: "Mem South" })
+      .first()
+      .click();
+    await expect(page).toHaveURL(/\/profile\/[0-9a-f-]{36}/);
+    await expect(page.getByText("Mem South").first()).toBeVisible();
+    await expect(page.locator('nav[aria-label="Breadcrumb"]')).toBeVisible();
+  });
+
+  test("clicking a member node shows the node detail page", async ({ page }) => {
+    await page.goto("/members");
+
+    const card = page
+      .getByTestId("member-card")
+      .filter({ hasText: "Mem South" })
+      .first();
+    await card
+      .locator(`[data-testid="member-node-badge"][data-node-key="${BRAVO_KEY}"]`)
+      .click();
+    await expect(page).toHaveURL(new RegExp(`/nodes/${BRAVO_KEY}`));
+    await expect(page.getByText("Bravo Node").first()).toBeVisible();
+  });
+});
diff --git a/e2e/tests/messages.spec.ts b/e2e/tests/messages.spec.ts
new file mode 100644
index 0000000..c06dca2
--- /dev/null
+++ b/e2e/tests/messages.spec.ts
@@ -0,0 +1,69 @@
+import { expect, test } from "@playwright/test";
+import { countApiCalls, expectListLoaded, openFilters } from "../utils/helpers";
+
+test.describe("messages", () => {
+  test("filter options work", async ({ page }) => {
+    await page.goto("/messages");
+    await expectListLoaded(page);
+    const table = page.locator("table");
+    await expect(page.getByTestId("list-row")).toHaveCount(4);
+
+    await openFilters(page);
+    await page.locator('select[name="message_type"]').selectOption("channel");
+    await page.getByRole("button", { name: "Filter" }).click();
+    await expect(page).toHaveURL(/message_type=channel/);
+    await expect(page.getByTestId("list-row")).toHaveCount(3);
+
+    // The channel select auto-submits on change.
+    await openFilters(page);
+    await page
+      .locator('select[name="channel_idx"]')
+      .selectOption({ label: "E2E General" });
+    await expect(page).toHaveURL(/channel_idx=\d+/);
+    await expect(page.getByTestId("list-row")).toHaveCount(1);
+    await expect(table.getByText("Ops channel traffic")).toBeVisible();
+
+    await page.goto("/messages");
+    await expectListLoaded(page);
+    await expect(page.getByTestId("list-row")).toHaveCount(4);
+  });
+
+  test("auto-refresh works and can be paused", async ({ page }) => {
+    await page.goto("/messages");
+    await expectListLoaded(page);
+
+    const toggle = page.getByTestId("auto-refresh-toggle");
+    await expect(toggle).toBeChecked();
+
+    const active = await countApiCalls(page, "/api/v1/messages?", 5000);
+    expect(active).toBeGreaterThanOrEqual(2);
+
+    await toggle.click();
+    await expect(toggle).not.toBeChecked();
+    const paused = await countApiCalls(page, "/api/v1/messages?", 4500);
+    expect(paused).toBe(0);
+  });
+
+  test("table row actions and observer toggle work", async ({ page }) => {
+    await page.goto("/messages");
+    await expectListLoaded(page);
+    const table = page.locator("table");
+
+    await expect(table.locator("span.observer-badge").first()).toBeVisible();
+
+    // Click a plain-text cell (Time) so the row handler navigates.
+    await page.getByTestId("list-row").first().locator("td").nth(1).click();
+    await expect(page).toHaveURL(/\/packets\/hash\//);
+
+    await page.goto("/messages");
+    await expectListLoaded(page);
+    await expect(page.getByTestId("list-row")).toHaveCount(4);
+
+    const north = page
+      .locator('[data-testid="observer-area"][data-area="North"]')
+      .first();
+    await north.click();
+    await expect(north).toHaveClass(/badge-ghost/);
+    await expect(page.getByTestId("list-row")).toHaveCount(2);
+  });
+});
diff --git a/e2e/tests/nodes.spec.ts b/e2e/tests/nodes.spec.ts
new file mode 100644
index 0000000..3be61bb
--- /dev/null
+++ b/e2e/tests/nodes.spec.ts
@@ -0,0 +1,69 @@
+import { expect, test } from "@playwright/test";
+import { countApiCalls, expectListLoaded, openFilters } from "../utils/helpers";
+
+const ALPHA_KEY = "a1fa" + "0".repeat(60);
+
+test.use({ permissions: ["clipboard-read", "clipboard-write"] });
+
+test.describe("nodes", () => {
+  test("filter options work", async ({ page }) => {
+    await page.goto("/nodes");
+    await expectListLoaded(page);
+    // Scope to the desktop table: rows also exist as hidden mobile cards.
+    const table = page.locator("table");
+    const initialCount = await page.getByTestId("list-row").count();
+    expect(initialCount).toBeGreaterThanOrEqual(4);
+
+    await openFilters(page);
+
+    await page.locator('input[name="search"]').fill("Alpha");
+    await page.getByRole("button", { name: "Filter" }).click();
+    await expect(page).toHaveURL(/search=Alpha/);
+    await expect(page.getByTestId("list-row")).toHaveCount(1);
+    await expect(table.getByText("Alpha Node").first()).toBeVisible();
+
+    await page.getByRole("link", { name: "Clear" }).click();
+    await expect(page).not.toHaveURL(/search=/);
+    await expect(page.getByTestId("list-row")).toHaveCount(initialCount);
+
+    await openFilters(page);
+    await page.locator('select[name="adv_type"]').selectOption("repeater");
+    await page.getByRole("button", { name: "Filter" }).click();
+    await expect(page).toHaveURL(/adv_type=repeater/);
+    await expect(table.getByText("Alpha Node")).toHaveCount(0);
+    await expect(table.getByText("Bravo Node").first()).toBeVisible();
+  });
+
+  test("auto-refresh works and can be paused", async ({ page }) => {
+    await page.goto("/nodes");
+    await expectListLoaded(page);
+
+    const toggle = page.getByTestId("auto-refresh-toggle");
+    await expect(toggle).toBeVisible();
+    await expect(toggle).toBeChecked();
+
+    const active = await countApiCalls(page, "/api/v1/nodes?", 5000);
+    expect(active).toBeGreaterThanOrEqual(2);
+
+    await toggle.click();
+    await expect(toggle).not.toBeChecked();
+    const paused = await countApiCalls(page, "/api/v1/nodes?", 4500);
+    expect(paused).toBe(0);
+  });
+
+  test("table row actions work", async ({ page }) => {
+    await page.goto("/nodes");
+    await expectListLoaded(page);
+    const table = page.locator("table");
+
+    await table.getByRole("link", { name: "Alpha Node" }).first().click();
+    await expect(page).toHaveURL(new RegExp(`/nodes/${ALPHA_KEY}`));
+    await expect(page.getByText("Alpha Node").first()).toBeVisible();
+    await page.goBack();
+    await expectListLoaded(page);
+
+    const copyable = table.locator('code[title="Click to copy"]').first();
+    await copyable.click();
+    await expect(page.getByText("Copied!").first()).toBeVisible();
+  });
+});
diff --git a/e2e/tests/packets.spec.ts b/e2e/tests/packets.spec.ts
new file mode 100644
index 0000000..d74f72a
--- /dev/null
+++ b/e2e/tests/packets.spec.ts
@@ -0,0 +1,91 @@
+import { expect, test } from "@playwright/test";
+import { countApiCalls, expectListLoaded, openFilters } from "../utils/helpers";
+
+const ALPHA_KEY = "a1fa" + "0".repeat(60);
+const AD01_HASH = "ad01" + "0".repeat(28);
+
+test.describe("packets", () => {
+  test("filter options work", async ({ page }) => {
+    await page.goto("/packets");
+    await expectListLoaded(page);
+    await expect(page.getByTestId("list-row")).toHaveCount(10);
+
+    await openFilters(page);
+    await page.locator('select[name="event_type"]').selectOption("advertisement");
+    await page.getByRole("button", { name: "Filter" }).click();
+    await expect(page).toHaveURL(/event_type=advertisement/);
+    await expect(page.getByTestId("list-row")).toHaveCount(6);
+
+    await openFilters(page);
+    await page.locator('select[name="path_hash_bytes"]').selectOption("2");
+    await page.getByRole("button", { name: "Filter" }).click();
+    await expect(page).toHaveURL(/path_hash_bytes=2/);
+    await expect(page.getByTestId("list-row")).toHaveCount(6);
+  });
+
+  test("auto-refresh works and can be paused", async ({ page }) => {
+    await page.goto("/packets");
+    await expectListLoaded(page);
+
+    const toggle = page.getByTestId("auto-refresh-toggle");
+    await expect(toggle).toBeChecked();
+
+    const active = await countApiCalls(page, "/api/v1/packet-groups?", 5000);
+    expect(active).toBeGreaterThanOrEqual(2);
+
+    await toggle.click();
+    await expect(toggle).not.toBeChecked();
+    const paused = await countApiCalls(page, "/api/v1/packet-groups?", 4500);
+    expect(paused).toBe(0);
+  });
+
+  test("row click opens the packet group detail", async ({ page }) => {
+    await page.goto("/packets");
+    await expectListLoaded(page);
+
+    await page.getByTestId("list-row").first().click();
+    await expect(page).toHaveURL(/\/packets\/hash\//);
+    await expect(page.locator('nav[aria-label="Breadcrumb"]')).toBeVisible();
+  });
+
+  test("clicking a path node renders the matching-nodes overlay", async ({
+    page,
+  }) => {
+    await page.goto(`/packets/hash/${AD01_HASH}`);
+
+    // Badges render twice (desktop table + hidden mobile cards): scope to visible.
+    const pathHops = page.locator('[data-testid="path-hop"]:visible');
+    await expect(pathHops.first()).toBeVisible();
+    for (const hash of ["A1FA", "B2B0", "C3C0"]) {
+      await expect(
+        page.locator(`[data-testid="path-hop"][data-hash="${hash}"]:visible`).first(),
+      ).toBeVisible();
+    }
+
+    await page
+      .locator('[data-testid="path-hop"][data-hash="A1FA"]:visible')
+      .first()
+      .click();
+    const popover = page.getByTestId("path-nodes-popover");
+    await expect(popover).toBeVisible();
+    await expect(popover.getByText("Nodes matching A1FA")).toBeVisible();
+    await expect(popover.getByText("Alpha Node")).toBeVisible();
+
+    await popover.getByTestId("path-node-link").first().click();
+    await expect(page).toHaveURL(new RegExp(`/nodes/${ALPHA_KEY}`));
+    await expect(page.getByText("Alpha Node").first()).toBeVisible();
+
+    await page.goto(`/packets/hash/${AD01_HASH}`);
+    await page
+      .locator('[data-testid="path-hop"][data-hash="B2B0"]:visible')
+      .first()
+      .click();
+    await expect(page.getByTestId("path-nodes-popover")).toBeVisible();
+    await expect(page.getByText("Bravo Node").first()).toBeVisible();
+    await page
+      .getByTestId("path-nodes-popover")
+      .getByRole("button", { name: "close" })
+      .click();
+    await expect(page.getByTestId("path-nodes-popover")).toHaveCount(0);
+  });
+});
diff --git a/e2e/tests/routes.spec.ts b/e2e/tests/routes.spec.ts
new file mode 100644
index 0000000..cfaa56a
--- /dev/null
+++ b/e2e/tests/routes.spec.ts
@@ -0,0 +1,137 @@
+import { expect, test } from "@playwright/test";
+import { ADMIN_STATE } from "../utils/helpers";
+
+test.use({ storageState: ADMIN_STATE });
+
+const ROUTE_LABEL = "E2E From \u2192 E2E To";
+
+test.describe.serial("routes (admin)", () => {
+  test("add route displays the modal and all options are persisted", async ({
+    page,
+  }) => {
+    await page.goto("/routes");
+
+    await expect(
+      page.locator('[data-testid="route-card"][data-route-label="Alpha Site \u2192 Bravo Site"]'),
+    ).toBeVisible();
+
+    await page.getByTestId("add-route").click();
+    const modal = page.locator('[data-testid="route-modal"]');
+    await expect(modal).toBeVisible();
+    await expect(page.locator("dialog h3")).toHaveText("Add Route");
+
+    await page.getByTestId("route-from").fill("E2E From");
+    await page.getByTestId("route-to").fill("E2E To");
+    await page.getByTestId("route-description").fill("Created by Playwright");
+    await page.getByTestId("route-visibility").selectOption("operator");
+    await page.locator('[data-testid="route-width"][data-width="2"]').click();
+    await expect(
+      page.locator('[data-testid="route-width"][data-width="2"]'),
+    ).toHaveClass(/btn-primary/);
+
+    await page.getByTestId("route-path-search").fill("Alpha");
+    await page.getByTestId("node-search-result").first().click();
+    await expect(page.getByTestId("route-path-chip")).toHaveCount(1);
+
+    await page.getByTestId("route-path-search").fill("Bravo");
+    await page.getByTestId("node-search-result").first().click();
+    await expect(page.getByTestId("route-path-chip")).toHaveCount(2);
+    await expect(modal.getByText("Alpha Node")).toBeVisible();
+    await expect(modal.getByText("Bravo Node")).toBeVisible();
+
+    await page.getByTestId("route-observer-search").fill("North");
+    await page.getByTestId("node-search-result").first().click();
+    await expect(modal.getByText(/North Observer/).first()).toBeVisible();
+
+    await page.getByTestId("route-window").fill("72");
+    await page.getByTestId("route-threshold").fill("4");
+    await page.getByTestId("route-clear-threshold").fill("8");
+    await page.getByTestId("route-max-span").fill("6");
+    await page.getByTestId("route-max-path-length").fill("5");
+    await page.getByTestId("route-enabled").setChecked(false);
+    await page.getByTestId("route-reversible").setChecked(false);
+
+    await page.getByTestId("route-save").click();
+    await expect(modal).toHaveCount(0);
+
+    const card = page.locator(
+      `[data-testid="route-card"][data-route-label="${ROUTE_LABEL}"]`,
+    );
+    await expect(card).toBeVisible();
+
+    await card.getByTestId("edit-route").click();
+    await expect(modal).toBeVisible();
+    await expect(page.locator("dialog h3")).toHaveText("Edit Route");
+
+    await expect(page.getByTestId("route-from")).toHaveValue("E2E From");
+    await expect(page.getByTestId("route-to")).toHaveValue("E2E To");
+    await expect(page.getByTestId("route-description")).toHaveValue(
+      "Created by Playwright",
+    );
+    await expect(page.getByTestId("route-visibility")).toHaveValue("operator");
+    await expect(
+      page.locator('[data-testid="route-width"][data-width="2"]'),
+    ).toHaveClass(/btn-primary/);
+    await expect(page.getByTestId("route-path-chip")).toHaveCount(2);
+    await expect(modal.getByText("Alpha Node")).toBeVisible();
+    await expect(modal.getByText("Bravo Node")).toBeVisible();
+    await expect(modal.getByText(/North Observer/).first()).toBeVisible();
+    await expect(page.getByTestId("route-window")).toHaveValue("72");
+    await expect(page.getByTestId("route-threshold")).toHaveValue("4");
+    await expect(page.getByTestId("route-clear-threshold")).toHaveValue("8");
+    await expect(page.getByTestId("route-max-span")).toHaveValue("6");
+    await expect(page.getByTestId("route-max-path-length")).toHaveValue("5");
+    await expect(page.getByTestId("route-enabled")).not.toBeChecked();
+    await expect(page.getByTestId("route-reversible")).not.toBeChecked();
+
+    await page.getByTestId("route-cancel").click();
+    await expect(modal).toHaveCount(0);
+  });
+
+  test("saving with fewer than 2 path nodes is rejected", async ({ page }) => {
+    await page.goto("/routes");
+    await page.getByTestId("add-route").click();
+
+    await page.getByTestId("route-from").fill("Bad");
+    await page.getByTestId("route-to").fill("Route");
+    await page.getByTestId("route-path-search").fill("Alpha");
+    await page.getByTestId("node-search-result").first().click();
+    await expect(page.getByTestId("route-path-chip")).toHaveCount(1);
+
+    // The alert blocks the page until dismissed, so accept it the moment it
+    // appears (handling it only after the awaited click would deadlock).
+    const dialogPromise = page.waitForEvent("dialog");
+    void dialogPromise.then((dialog) => dialog.accept());
+    await page.getByTestId("route-save").click();
+    expect((await dialogPromise).message()).toBe(
+      "At least 2 path nodes are required.",
+    );
+
+    await expect(page.locator('[data-testid="route-modal"]')).toBeVisible();
+    await page.getByTestId("route-cancel").click();
+  });
+
+  test("delete route shows a confirm dialog and removes the route", async ({
+    page,
+  }) => {
+    await page.goto("/routes");
+
+    const card = page.locator(
+      `[data-testid="route-card"][data-route-label="${ROUTE_LABEL}"]`,
+    );
+    await expect(card).toBeVisible();
+    await card.getByTestId("delete-route").click();
+
+    const confirm = page.locator("dialog.modal-open");
+    await expect(confirm).toBeVisible();
+    await expect(
+      confirm.getByRole("heading", { name: "Delete Route" }),
+    ).toBeVisible();
+    await expect(
+      confirm.getByText(/Are you sure you want to delete route/),
+    ).toBeVisible();
+
+    await confirm.getByRole("button", { name: "Delete" }).click();
+    await expect(card).toHaveCount(0);
+  });
+});
diff --git a/e2e/tests/users.spec.ts b/e2e/tests/users.spec.ts
new file mode 100644
index 0000000..42ecd0f
--- /dev/null
+++ b/e2e/tests/users.spec.ts
@@ -0,0 +1,47 @@
+import { expect, test } from "@playwright/test";
+import { MEMBER_STATE } from "../utils/helpers";
+
+test.use({ storageState: MEMBER_STATE });
+
+test.describe("users", () => {
+  test("user profile menu works", async ({ page }) => {
+    await page.goto("/");
+
+    await page.getByTestId("user-menu").click();
+    await expect(page.getByText("PW Member")).toBeVisible();
+    await expect(page.getByText("member", { exact: true })).toBeVisible();
+    await expect(page.getByTestId("user-menu-profile")).toBeVisible();
+    await expect(page.getByTestId("user-menu-logout")).toBeVisible();
+  });
+
+  test("profile edit works and persists", async ({ page }) => {
+    await page.goto("/profile");
+
+    await expect(page.locator('input[name="name"]')).toBeVisible();
+    await page.locator('input[name="name"]').fill("PW Member Edited");
+    await page.locator('input[name="callsign"]').fill("E2EEDIT");
+    await page
+      .locator('input[name="description"]')
+      .fill("Updated by Playwright");
+    await page
+      .locator('input[name="url"]')
+      .fill("https://example.com/pw-member");
+
+    await page.getByRole("button", { name: "Save Profile" }).click();
+    await expect(page.getByRole("alert")).toContainText(
+      "Profile updated successfully",
+    );
+
+    await page.reload();
+    await expect(page.locator('input[name="name"]')).toHaveValue(
+      "PW Member Edited",
+    );
+    await expect(page.locator('input[name="callsign"]')).toHaveValue("E2EEDIT");
+    await expect(page.locator('input[name="description"]')).toHaveValue(
+      "Updated by Playwright",
+    );
+    await expect(page.locator('input[name="url"]')).toHaveValue(
+      "https://example.com/pw-member",
+    );
+  });
+});
diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json
new file mode 100644
index 0000000..3b1ed25
--- /dev/null
+++ b/e2e/tsconfig.json
@@ -0,0 +1,16 @@
+{
+  "compilerOptions": {
+    "target": "ES2022",
+    "lib": ["ES2022", "DOM", "DOM.Iterable"],
+    "module": "ESNext",
+    "moduleResolution": "bundler",
+    "strict": true,
+    "noEmit": true,
+    "skipLibCheck": true,
+    "esModuleInterop": true,
+    "forceConsistentCasingInFileNames": true,
+    "resolveJsonModule": true,
+    "types": ["node"]
+  },
+  "include": ["./**/*.ts"]
+}
diff --git a/e2e/utils/helpers.ts b/e2e/utils/helpers.ts
new file mode 100644
index 0000000..2bd023e
--- /dev/null
+++ b/e2e/utils/helpers.ts
@@ -0,0 +1,39 @@
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { expect, type Page } from "@playwright/test";
+
+const AUTH_DIR = path.resolve(
+  path.dirname(fileURLToPath(import.meta.url)),
+  "..",
+  ".auth",
+);
+export const ADMIN_STATE = path.join(AUTH_DIR, "admin.json");
+export const MEMBER_STATE = path.join(AUTH_DIR, "member.json");
+
+export async function expectListLoaded(page: Page): Promise {
+  await expect(page.getByTestId("list-row").first()).toBeVisible();
+}
+
+export async function openFilters(page: Page): Promise {
+  const toggle = page.locator("#filter-toggle");
+  if (!(await toggle.isChecked())) {
+    await toggle.click();
+  }
+}
+
+export async function countApiCalls(
+  page: Page,
+  urlFragment: string,
+  durationMs: number,
+): Promise {
+  let count = 0;
+  const onRequest = (request: { url: () => string }): void => {
+    if (request.url().includes(urlFragment)) {
+      count += 1;
+    }
+  };
+  page.on("request", onRequest);
+  await page.waitForTimeout(durationMs);
+  page.off("request", onRequest);
+  return count;
+}
diff --git a/package-lock.json b/package-lock.json
index bc07d3f..05d2f70 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,457 +8,562 @@
         "@fontsource-variable/ibm-plex-sans": "^5",
         "@fontsource/ibm-plex-mono": "^5",
         "@tailwindcss/cli": "^4",
+        "@tanstack/react-query": "^5.101.4",
         "chart.js": "^4",
         "daisyui": "^5",
+        "i18next": "^25",
+        "i18next-browser-languagedetector": "^8",
         "leaflet": "^1.9.4",
-        "lit-html": "^3",
-        "qrcodejs": "^1.0.0",
+        "react": "^19",
+        "react-chartjs-2": "^5",
+        "react-dom": "^19",
+        "react-i18next": "^15",
+        "react-leaflet": "^5",
+        "react-markdown": "^10.1.0",
+        "react-qr-code": "^2.2.0",
+        "react-router": "^7",
+        "rehype-autolink-headings": "^7.1.0",
+        "rehype-slug": "^6.0.0",
+        "remark-gfm": "^4.0.1",
         "tailwindcss": "^4"
       },
       "devDependencies": {
-        "esbuild": "^0.28.0"
+        "@playwright/test": "^1.61.1",
+        "@testing-library/dom": "^10.4.1",
+        "@testing-library/jest-dom": "^7.0.0",
+        "@testing-library/react": "^16.3.2",
+        "@types/leaflet": "^1.9.17",
+        "@types/node": "^24.0.0",
+        "@types/react": "^19",
+        "@types/react-dom": "^19",
+        "@vitejs/plugin-react": "^4",
+        "jsdom": "^29.1.1",
+        "typescript": "^5.8",
+        "vite": "^6",
+        "vitest": "^4.1.10"
+      },
+      "engines": {
+        "node": ">=20"
       }
     },
-    "node_modules/@esbuild/aix-ppc64": {
-      "version": "0.28.1",
-      "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
-      "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
-      "cpu": [
-        "ppc64"
-      ],
+    "node_modules/@adobe/css-tools": {
+      "version": "4.5.0",
+      "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz",
+      "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@asamuzakjp/css-color": {
+      "version": "5.1.11",
+      "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
+      "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==",
       "dev": true,
       "license": "MIT",
-      "optional": true,
-      "os": [
-        "aix"
-      ],
+      "dependencies": {
+        "@asamuzakjp/generational-cache": "^1.0.1",
+        "@csstools/css-calc": "^3.2.0",
+        "@csstools/css-color-parser": "^4.1.0",
+        "@csstools/css-parser-algorithms": "^4.0.0",
+        "@csstools/css-tokenizer": "^4.0.0"
+      },
       "engines": {
-        "node": ">=18"
+        "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
       }
     },
-    "node_modules/@esbuild/android-arm": {
-      "version": "0.28.1",
-      "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
-      "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
-      "cpu": [
-        "arm"
-      ],
+    "node_modules/@asamuzakjp/dom-selector": {
+      "version": "7.1.1",
+      "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz",
+      "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==",
       "dev": true,
       "license": "MIT",
-      "optional": true,
-      "os": [
-        "android"
-      ],
+      "dependencies": {
+        "@asamuzakjp/generational-cache": "^1.0.1",
+        "@asamuzakjp/nwsapi": "^2.3.9",
+        "bidi-js": "^1.0.3",
+        "css-tree": "^3.2.1",
+        "is-potential-custom-element-name": "^1.0.1"
+      },
       "engines": {
-        "node": ">=18"
+        "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
       }
     },
-    "node_modules/@esbuild/android-arm64": {
-      "version": "0.28.1",
-      "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
-      "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
-      "cpu": [
-        "arm64"
-      ],
+    "node_modules/@asamuzakjp/generational-cache": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
+      "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
       "dev": true,
       "license": "MIT",
-      "optional": true,
-      "os": [
-        "android"
-      ],
       "engines": {
-        "node": ">=18"
+        "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
       }
     },
-    "node_modules/@esbuild/android-x64": {
-      "version": "0.28.1",
-      "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
-      "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
-      "cpu": [
-        "x64"
-      ],
+    "node_modules/@asamuzakjp/nwsapi": {
+      "version": "2.3.9",
+      "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
+      "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@babel/code-frame": {
+      "version": "7.29.7",
+      "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
+      "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
       "dev": true,
       "license": "MIT",
-      "optional": true,
-      "os": [
-        "android"
-      ],
+      "dependencies": {
+        "@babel/helper-validator-identifier": "^7.29.7",
+        "js-tokens": "^4.0.0",
+        "picocolors": "^1.1.1"
+      },
       "engines": {
-        "node": ">=18"
+        "node": ">=6.9.0"
       }
     },
-    "node_modules/@esbuild/darwin-arm64": {
-      "version": "0.28.1",
-      "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
-      "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
-      "cpu": [
-        "arm64"
-      ],
+    "node_modules/@babel/compat-data": {
+      "version": "7.29.7",
+      "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
+      "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
       "dev": true,
       "license": "MIT",
-      "optional": true,
-      "os": [
-        "darwin"
-      ],
       "engines": {
-        "node": ">=18"
+        "node": ">=6.9.0"
       }
     },
-    "node_modules/@esbuild/darwin-x64": {
-      "version": "0.28.1",
-      "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
-      "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
-      "cpu": [
-        "x64"
-      ],
+    "node_modules/@babel/core": {
+      "version": "7.29.7",
+      "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
+      "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
       "dev": true,
       "license": "MIT",
-      "optional": true,
-      "os": [
-        "darwin"
-      ],
+      "dependencies": {
+        "@babel/code-frame": "^7.29.7",
+        "@babel/generator": "^7.29.7",
+        "@babel/helper-compilation-targets": "^7.29.7",
+        "@babel/helper-module-transforms": "^7.29.7",
+        "@babel/helpers": "^7.29.7",
+        "@babel/parser": "^7.29.7",
+        "@babel/template": "^7.29.7",
+        "@babel/traverse": "^7.29.7",
+        "@babel/types": "^7.29.7",
+        "@jridgewell/remapping": "^2.3.5",
+        "convert-source-map": "^2.0.0",
+        "debug": "^4.1.0",
+        "gensync": "^1.0.0-beta.2",
+        "json5": "^2.2.3",
+        "semver": "^6.3.1"
+      },
       "engines": {
-        "node": ">=18"
+        "node": ">=6.9.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/babel"
       }
     },
-    "node_modules/@esbuild/freebsd-arm64": {
-      "version": "0.28.1",
-      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
-      "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
-      "cpu": [
-        "arm64"
-      ],
+    "node_modules/@babel/generator": {
+      "version": "7.29.7",
+      "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
+      "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
       "dev": true,
       "license": "MIT",
-      "optional": true,
-      "os": [
-        "freebsd"
-      ],
+      "dependencies": {
+        "@babel/parser": "^7.29.7",
+        "@babel/types": "^7.29.7",
+        "@jridgewell/gen-mapping": "^0.3.12",
+        "@jridgewell/trace-mapping": "^0.3.28",
+        "jsesc": "^3.0.2"
+      },
       "engines": {
-        "node": ">=18"
+        "node": ">=6.9.0"
       }
     },
-    "node_modules/@esbuild/freebsd-x64": {
-      "version": "0.28.1",
-      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
-      "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
-      "cpu": [
-        "x64"
-      ],
+    "node_modules/@babel/helper-compilation-targets": {
+      "version": "7.29.7",
+      "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
+      "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
       "dev": true,
       "license": "MIT",
-      "optional": true,
-      "os": [
-        "freebsd"
-      ],
+      "dependencies": {
+        "@babel/compat-data": "^7.29.7",
+        "@babel/helper-validator-option": "^7.29.7",
+        "browserslist": "^4.24.0",
+        "lru-cache": "^5.1.1",
+        "semver": "^6.3.1"
+      },
       "engines": {
-        "node": ">=18"
+        "node": ">=6.9.0"
       }
     },
-    "node_modules/@esbuild/linux-arm": {
-      "version": "0.28.1",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
-      "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
-      "cpu": [
-        "arm"
-      ],
+    "node_modules/@babel/helper-globals": {
+      "version": "7.29.7",
+      "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
+      "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
       "dev": true,
       "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
       "engines": {
-        "node": ">=18"
+        "node": ">=6.9.0"
       }
     },
-    "node_modules/@esbuild/linux-arm64": {
-      "version": "0.28.1",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
-      "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
-      "cpu": [
-        "arm64"
-      ],
+    "node_modules/@babel/helper-module-imports": {
+      "version": "7.29.7",
+      "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
+      "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
       "dev": true,
       "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
+      "dependencies": {
+        "@babel/traverse": "^7.29.7",
+        "@babel/types": "^7.29.7"
+      },
       "engines": {
-        "node": ">=18"
+        "node": ">=6.9.0"
       }
     },
-    "node_modules/@esbuild/linux-ia32": {
-      "version": "0.28.1",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
-      "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
-      "cpu": [
-        "ia32"
-      ],
+    "node_modules/@babel/helper-module-transforms": {
+      "version": "7.29.7",
+      "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
+      "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
       "dev": true,
       "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
+      "dependencies": {
+        "@babel/helper-module-imports": "^7.29.7",
+        "@babel/helper-validator-identifier": "^7.29.7",
+        "@babel/traverse": "^7.29.7"
+      },
       "engines": {
-        "node": ">=18"
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0"
       }
     },
-    "node_modules/@esbuild/linux-loong64": {
-      "version": "0.28.1",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
-      "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
-      "cpu": [
-        "loong64"
-      ],
+    "node_modules/@babel/helper-plugin-utils": {
+      "version": "7.29.7",
+      "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz",
+      "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==",
       "dev": true,
       "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
       "engines": {
-        "node": ">=18"
+        "node": ">=6.9.0"
       }
     },
-    "node_modules/@esbuild/linux-mips64el": {
-      "version": "0.28.1",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
-      "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
-      "cpu": [
-        "mips64el"
-      ],
+    "node_modules/@babel/helper-string-parser": {
+      "version": "7.29.7",
+      "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
+      "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
       "dev": true,
       "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
       "engines": {
-        "node": ">=18"
+        "node": ">=6.9.0"
       }
     },
-    "node_modules/@esbuild/linux-ppc64": {
-      "version": "0.28.1",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
-      "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
-      "cpu": [
-        "ppc64"
-      ],
+    "node_modules/@babel/helper-validator-identifier": {
+      "version": "7.29.7",
+      "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+      "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
       "dev": true,
       "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
       "engines": {
-        "node": ">=18"
+        "node": ">=6.9.0"
       }
     },
-    "node_modules/@esbuild/linux-riscv64": {
-      "version": "0.28.1",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
-      "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
-      "cpu": [
-        "riscv64"
-      ],
+    "node_modules/@babel/helper-validator-option": {
+      "version": "7.29.7",
+      "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
+      "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
       "dev": true,
       "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
       "engines": {
-        "node": ">=18"
+        "node": ">=6.9.0"
       }
     },
-    "node_modules/@esbuild/linux-s390x": {
-      "version": "0.28.1",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
-      "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
-      "cpu": [
-        "s390x"
-      ],
+    "node_modules/@babel/helpers": {
+      "version": "7.29.7",
+      "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
+      "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
       "dev": true,
       "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
+      "dependencies": {
+        "@babel/template": "^7.29.7",
+        "@babel/types": "^7.29.7"
+      },
       "engines": {
-        "node": ">=18"
+        "node": ">=6.9.0"
       }
     },
-    "node_modules/@esbuild/linux-x64": {
-      "version": "0.28.1",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
-      "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
-      "cpu": [
-        "x64"
-      ],
+    "node_modules/@babel/parser": {
+      "version": "7.29.7",
+      "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
+      "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
       "dev": true,
       "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
+      "dependencies": {
+        "@babel/types": "^7.29.7"
+      },
+      "bin": {
+        "parser": "bin/babel-parser.js"
+      },
       "engines": {
-        "node": ">=18"
+        "node": ">=6.0.0"
       }
     },
-    "node_modules/@esbuild/netbsd-arm64": {
-      "version": "0.28.1",
-      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
-      "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
-      "cpu": [
-        "arm64"
-      ],
+    "node_modules/@babel/plugin-transform-react-jsx-self": {
+      "version": "7.29.7",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz",
+      "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==",
       "dev": true,
       "license": "MIT",
-      "optional": true,
-      "os": [
-        "netbsd"
-      ],
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.29.7"
+      },
       "engines": {
-        "node": ">=18"
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
       }
     },
-    "node_modules/@esbuild/netbsd-x64": {
-      "version": "0.28.1",
-      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
-      "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
-      "cpu": [
-        "x64"
-      ],
+    "node_modules/@babel/plugin-transform-react-jsx-source": {
+      "version": "7.29.7",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz",
+      "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==",
       "dev": true,
       "license": "MIT",
-      "optional": true,
-      "os": [
-        "netbsd"
-      ],
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.29.7"
+      },
       "engines": {
-        "node": ">=18"
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
       }
     },
-    "node_modules/@esbuild/openbsd-arm64": {
-      "version": "0.28.1",
-      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
-      "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
+    "node_modules/@babel/runtime": {
+      "version": "7.29.7",
+      "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
+      "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
       "license": "MIT",
-      "optional": true,
-      "os": [
-        "openbsd"
-      ],
       "engines": {
-        "node": ">=18"
+        "node": ">=6.9.0"
       }
     },
-    "node_modules/@esbuild/openbsd-x64": {
-      "version": "0.28.1",
-      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
-      "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
-      "cpu": [
-        "x64"
-      ],
+    "node_modules/@babel/template": {
+      "version": "7.29.7",
+      "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
+      "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
       "dev": true,
       "license": "MIT",
-      "optional": true,
-      "os": [
-        "openbsd"
-      ],
+      "dependencies": {
+        "@babel/code-frame": "^7.29.7",
+        "@babel/parser": "^7.29.7",
+        "@babel/types": "^7.29.7"
+      },
       "engines": {
-        "node": ">=18"
+        "node": ">=6.9.0"
       }
     },
-    "node_modules/@esbuild/openharmony-arm64": {
-      "version": "0.28.1",
-      "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
-      "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
-      "cpu": [
-        "arm64"
-      ],
+    "node_modules/@babel/traverse": {
+      "version": "7.29.7",
+      "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
+      "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
       "dev": true,
       "license": "MIT",
-      "optional": true,
-      "os": [
-        "openharmony"
-      ],
+      "dependencies": {
+        "@babel/code-frame": "^7.29.7",
+        "@babel/generator": "^7.29.7",
+        "@babel/helper-globals": "^7.29.7",
+        "@babel/parser": "^7.29.7",
+        "@babel/template": "^7.29.7",
+        "@babel/types": "^7.29.7",
+        "debug": "^4.3.1"
+      },
       "engines": {
-        "node": ">=18"
+        "node": ">=6.9.0"
       }
     },
-    "node_modules/@esbuild/sunos-x64": {
-      "version": "0.28.1",
-      "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
-      "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
-      "cpu": [
-        "x64"
-      ],
+    "node_modules/@babel/types": {
+      "version": "7.29.7",
+      "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
+      "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
       "dev": true,
       "license": "MIT",
-      "optional": true,
-      "os": [
-        "sunos"
-      ],
+      "dependencies": {
+        "@babel/helper-string-parser": "^7.29.7",
+        "@babel/helper-validator-identifier": "^7.29.7"
+      },
       "engines": {
-        "node": ">=18"
+        "node": ">=6.9.0"
       }
     },
-    "node_modules/@esbuild/win32-arm64": {
-      "version": "0.28.1",
-      "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
-      "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
-      "cpu": [
-        "arm64"
-      ],
+    "node_modules/@bramus/specificity": {
+      "version": "2.4.2",
+      "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
+      "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
       "dev": true,
       "license": "MIT",
-      "optional": true,
-      "os": [
-        "win32"
-      ],
-      "engines": {
-        "node": ">=18"
+      "dependencies": {
+        "css-tree": "^3.0.0"
+      },
+      "bin": {
+        "specificity": "bin/cli.js"
       }
     },
-    "node_modules/@esbuild/win32-ia32": {
-      "version": "0.28.1",
-      "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
-      "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
-      "cpu": [
-        "ia32"
-      ],
+    "node_modules/@csstools/color-helpers": {
+      "version": "6.1.0",
+      "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz",
+      "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==",
       "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "win32"
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/csstools"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/csstools"
+        }
       ],
+      "license": "MIT-0",
       "engines": {
-        "node": ">=18"
+        "node": ">=20.19.0"
       }
     },
-    "node_modules/@esbuild/win32-x64": {
-      "version": "0.28.1",
-      "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
-      "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
-      "cpu": [
-        "x64"
+    "node_modules/@csstools/css-calc": {
+      "version": "3.2.1",
+      "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz",
+      "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/csstools"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/csstools"
+        }
       ],
+      "license": "MIT",
+      "engines": {
+        "node": ">=20.19.0"
+      },
+      "peerDependencies": {
+        "@csstools/css-parser-algorithms": "^4.0.0",
+        "@csstools/css-tokenizer": "^4.0.0"
+      }
+    },
+    "node_modules/@csstools/css-color-parser": {
+      "version": "4.1.9",
+      "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.9.tgz",
+      "integrity": "sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/csstools"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/csstools"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "@csstools/color-helpers": "^6.1.0",
+        "@csstools/css-calc": "^3.2.1"
+      },
+      "engines": {
+        "node": ">=20.19.0"
+      },
+      "peerDependencies": {
+        "@csstools/css-parser-algorithms": "^4.0.0",
+        "@csstools/css-tokenizer": "^4.0.0"
+      }
+    },
+    "node_modules/@csstools/css-parser-algorithms": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
+      "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/csstools"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/csstools"
+        }
+      ],
+      "license": "MIT",
+      "engines": {
+        "node": ">=20.19.0"
+      },
+      "peerDependencies": {
+        "@csstools/css-tokenizer": "^4.0.0"
+      }
+    },
+    "node_modules/@csstools/css-syntax-patches-for-csstree": {
+      "version": "1.1.6",
+      "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.6.tgz",
+      "integrity": "sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/csstools"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/csstools"
+        }
+      ],
+      "license": "MIT-0",
+      "peerDependencies": {
+        "css-tree": "^3.2.1"
+      },
+      "peerDependenciesMeta": {
+        "css-tree": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@csstools/css-tokenizer": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
+      "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/csstools"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/csstools"
+        }
+      ],
+      "license": "MIT",
+      "engines": {
+        "node": ">=20.19.0"
+      }
+    },
+    "node_modules/@exodus/bytes": {
+      "version": "1.15.1",
+      "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz",
+      "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==",
       "dev": true,
       "license": "MIT",
-      "optional": true,
-      "os": [
-        "win32"
-      ],
       "engines": {
-        "node": ">=18"
+        "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+      },
+      "peerDependencies": {
+        "@noble/hashes": "^1.8.0 || ^2.0.0"
+      },
+      "peerDependenciesMeta": {
+        "@noble/hashes": {
+          "optional": true
+        }
       }
     },
     "node_modules/@fontsource-variable/ibm-plex-sans": {
@@ -855,6 +960,436 @@
         "node": ">=0.10"
       }
     },
+    "node_modules/@playwright/test": {
+      "version": "1.61.1",
+      "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
+      "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "playwright": "1.61.1"
+      },
+      "bin": {
+        "playwright": "cli.js"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@react-leaflet/core": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz",
+      "integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==",
+      "license": "Hippocratic-2.1",
+      "peerDependencies": {
+        "leaflet": "^1.9.0",
+        "react": "^19.0.0",
+        "react-dom": "^19.0.0"
+      }
+    },
+    "node_modules/@rolldown/pluginutils": {
+      "version": "1.0.0-beta.27",
+      "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
+      "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@rollup/rollup-android-arm-eabi": {
+      "version": "4.62.2",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz",
+      "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/@rollup/rollup-android-arm64": {
+      "version": "4.62.2",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz",
+      "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/@rollup/rollup-darwin-arm64": {
+      "version": "4.62.2",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz",
+      "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@rollup/rollup-darwin-x64": {
+      "version": "4.62.2",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz",
+      "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@rollup/rollup-freebsd-arm64": {
+      "version": "4.62.2",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz",
+      "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-freebsd-x64": {
+      "version": "4.62.2",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz",
+      "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+      "version": "4.62.2",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz",
+      "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+      "version": "4.62.2",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz",
+      "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "libc": [
+        "musl"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm64-gnu": {
+      "version": "4.62.2",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz",
+      "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm64-musl": {
+      "version": "4.62.2",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz",
+      "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "libc": [
+        "musl"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-loong64-gnu": {
+      "version": "4.62.2",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz",
+      "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-loong64-musl": {
+      "version": "4.62.2",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz",
+      "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "libc": [
+        "musl"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+      "version": "4.62.2",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz",
+      "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-ppc64-musl": {
+      "version": "4.62.2",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz",
+      "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "libc": [
+        "musl"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+      "version": "4.62.2",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz",
+      "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-riscv64-musl": {
+      "version": "4.62.2",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz",
+      "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "libc": [
+        "musl"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-s390x-gnu": {
+      "version": "4.62.2",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz",
+      "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-x64-gnu": {
+      "version": "4.62.2",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz",
+      "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-x64-musl": {
+      "version": "4.62.2",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz",
+      "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "libc": [
+        "musl"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-openbsd-x64": {
+      "version": "4.62.2",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz",
+      "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-openharmony-arm64": {
+      "version": "4.62.2",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz",
+      "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openharmony"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-arm64-msvc": {
+      "version": "4.62.2",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz",
+      "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-ia32-msvc": {
+      "version": "4.62.2",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz",
+      "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-x64-gnu": {
+      "version": "4.62.2",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz",
+      "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-x64-msvc": {
+      "version": "4.62.2",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz",
+      "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@standard-schema/spec": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+      "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/@tailwindcss/cli": {
       "version": "4.3.3",
       "resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.3.3.tgz",
@@ -1188,12 +1723,496 @@
         "node": ">= 20"
       }
     },
-    "node_modules/@types/trusted-types": {
-      "version": "2.0.7",
-      "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
-      "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
+    "node_modules/@tanstack/query-core": {
+      "version": "5.101.4",
+      "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.4.tgz",
+      "integrity": "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/tannerlinsley"
+      }
+    },
+    "node_modules/@tanstack/react-query": {
+      "version": "5.101.4",
+      "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.4.tgz",
+      "integrity": "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==",
+      "license": "MIT",
+      "dependencies": {
+        "@tanstack/query-core": "5.101.4"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/tannerlinsley"
+      },
+      "peerDependencies": {
+        "react": "^18 || ^19"
+      }
+    },
+    "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",
+      "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": "7.0.0",
+      "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.0.tgz",
+      "integrity": "sha512-HKAH9C6mBo5yBG6yRO5i43L2iisencAo5z+o5P/saHUoY+miC5ivXRxHBJcFyB5ypPNxHJdK3BoF/3O4DIptMg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@adobe/css-tools": "^4.4.0",
+        "aria-query": "^5.0.0",
+        "css.escape": "^1.5.1",
+        "dom-accessibility-api": "^0.6.3",
+        "picocolors": "^1.1.1",
+        "redent": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=22",
+        "npm": ">=6",
+        "yarn": ">=1"
+      },
+      "peerDependencies": {
+        "@testing-library/dom": ">=10 <11"
+      }
+    },
+    "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
+      "version": "0.6.3",
+      "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
+      "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
+      "dev": true,
       "license": "MIT"
     },
+    "node_modules/@testing-library/react": {
+      "version": "16.3.2",
+      "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz",
+      "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.12.5"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "peerDependencies": {
+        "@testing-library/dom": "^10.0.0",
+        "@types/react": "^18.0.0 || ^19.0.0",
+        "@types/react-dom": "^18.0.0 || ^19.0.0",
+        "react": "^18.0.0 || ^19.0.0",
+        "react-dom": "^18.0.0 || ^19.0.0"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "@types/react-dom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@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"
+    },
+    "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/debug": {
+      "version": "4.1.13",
+      "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz",
+      "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/ms": "*"
+      }
+    },
+    "node_modules/@types/deep-eql": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+      "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/estree": {
+      "version": "1.0.9",
+      "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+      "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+      "license": "MIT"
+    },
+    "node_modules/@types/estree-jsx": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz",
+      "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree": "*"
+      }
+    },
+    "node_modules/@types/geojson": {
+      "version": "7946.0.16",
+      "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
+      "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/hast": {
+      "version": "3.0.5",
+      "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz",
+      "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/unist": "*"
+      }
+    },
+    "node_modules/@types/leaflet": {
+      "version": "1.9.21",
+      "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
+      "integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/geojson": "*"
+      }
+    },
+    "node_modules/@types/mdast": {
+      "version": "4.0.4",
+      "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
+      "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/unist": "*"
+      }
+    },
+    "node_modules/@types/ms": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
+      "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
+      "license": "MIT"
+    },
+    "node_modules/@types/node": {
+      "version": "24.13.3",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
+      "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "undici-types": "~7.18.0"
+      }
+    },
+    "node_modules/@types/react": {
+      "version": "19.2.17",
+      "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
+      "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
+      "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/unist": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
+      "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
+      "license": "MIT"
+    },
+    "node_modules/@ungap/structured-clone": {
+      "version": "1.3.3",
+      "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz",
+      "integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==",
+      "license": "ISC"
+    },
+    "node_modules/@vitejs/plugin-react": {
+      "version": "4.7.0",
+      "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
+      "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/core": "^7.28.0",
+        "@babel/plugin-transform-react-jsx-self": "^7.27.1",
+        "@babel/plugin-transform-react-jsx-source": "^7.27.1",
+        "@rolldown/pluginutils": "1.0.0-beta.27",
+        "@types/babel__core": "^7.20.5",
+        "react-refresh": "^0.17.0"
+      },
+      "engines": {
+        "node": "^14.18.0 || >=16.0.0"
+      },
+      "peerDependencies": {
+        "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
+      }
+    },
+    "node_modules/@vitest/expect": {
+      "version": "4.1.10",
+      "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
+      "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@standard-schema/spec": "^1.1.0",
+        "@types/chai": "^5.2.2",
+        "@vitest/spy": "4.1.10",
+        "@vitest/utils": "4.1.10",
+        "chai": "^6.2.2",
+        "tinyrainbow": "^3.1.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/@vitest/mocker": {
+      "version": "4.1.10",
+      "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz",
+      "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@vitest/spy": "4.1.10",
+        "estree-walker": "^3.0.3",
+        "magic-string": "^0.30.21"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      },
+      "peerDependencies": {
+        "msw": "^2.4.9",
+        "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+      },
+      "peerDependenciesMeta": {
+        "msw": {
+          "optional": true
+        },
+        "vite": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@vitest/pretty-format": {
+      "version": "4.1.10",
+      "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz",
+      "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "tinyrainbow": "^3.1.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/@vitest/runner": {
+      "version": "4.1.10",
+      "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz",
+      "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@vitest/utils": "4.1.10",
+        "pathe": "^2.0.3"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/@vitest/snapshot": {
+      "version": "4.1.10",
+      "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz",
+      "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@vitest/pretty-format": "4.1.10",
+        "@vitest/utils": "4.1.10",
+        "magic-string": "^0.30.21",
+        "pathe": "^2.0.3"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/@vitest/spy": {
+      "version": "4.1.10",
+      "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz",
+      "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==",
+      "dev": true,
+      "license": "MIT",
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/@vitest/utils": {
+      "version": "4.1.10",
+      "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz",
+      "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@vitest/pretty-format": "4.1.10",
+        "convert-source-map": "^2.0.0",
+        "tinyrainbow": "^3.1.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/ansi-regex": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+      "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/ansi-styles": {
+      "version": "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",
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+      }
+    },
+    "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/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/bail": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz",
+      "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/wooorm"
+      }
+    },
+    "node_modules/baseline-browser-mapping": {
+      "version": "2.10.44",
+      "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.44.tgz",
+      "integrity": "sha512-T3ghW+sl/ZJ8w1v/yQx3qvJ9040DWoLBz8JT/CILbAKcFyG9b2MRe75v6W5uXjv6uH1lumK2Kv46y2zSkcej0Q==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "baseline-browser-mapping": "dist/cli.cjs"
+      },
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/bidi-js": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
+      "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "require-from-string": "^2.0.2"
+      }
+    },
     "node_modules/braces": {
       "version": "3.0.3",
       "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
@@ -1206,6 +2225,121 @@
         "node": ">=8"
       }
     },
+    "node_modules/browserslist": {
+      "version": "4.28.6",
+      "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz",
+      "integrity": "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/browserslist"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/browserslist"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "baseline-browser-mapping": "^2.10.42",
+        "caniuse-lite": "^1.0.30001803",
+        "electron-to-chromium": "^1.5.389",
+        "node-releases": "^2.0.51",
+        "update-browserslist-db": "^1.2.3"
+      },
+      "bin": {
+        "browserslist": "cli.js"
+      },
+      "engines": {
+        "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+      }
+    },
+    "node_modules/caniuse-lite": {
+      "version": "1.0.30001806",
+      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz",
+      "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==",
+      "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/ccount": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",
+      "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/wooorm"
+      }
+    },
+    "node_modules/chai": {
+      "version": "6.2.2",
+      "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
+      "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/character-entities": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz",
+      "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/wooorm"
+      }
+    },
+    "node_modules/character-entities-html4": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz",
+      "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/wooorm"
+      }
+    },
+    "node_modules/character-entities-legacy": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz",
+      "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/wooorm"
+      }
+    },
+    "node_modules/character-reference-invalid": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz",
+      "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/wooorm"
+      }
+    },
     "node_modules/chart.js": {
       "version": "4.5.1",
       "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
@@ -1218,6 +2352,63 @@
         "pnpm": ">=8"
       }
     },
+    "node_modules/comma-separated-tokens": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
+      "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/wooorm"
+      }
+    },
+    "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": "1.1.1",
+      "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
+      "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/css-tree": {
+      "version": "3.2.1",
+      "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
+      "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "mdn-data": "2.27.1",
+        "source-map-js": "^1.2.1"
+      },
+      "engines": {
+        "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
+      }
+    },
+    "node_modules/css.escape": {
+      "version": "1.5.1",
+      "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
+      "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/csstype": {
+      "version": "3.2.3",
+      "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+      "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+      "license": "MIT"
+    },
     "node_modules/daisyui": {
       "version": "5.7.0",
       "resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.7.0.tgz",
@@ -1227,6 +2418,66 @@
         "url": "https://github.com/saadeghi/daisyui?sponsor=1"
       }
     },
+    "node_modules/data-urls": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
+      "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "whatwg-mimetype": "^5.0.0",
+        "whatwg-url": "^16.0.0"
+      },
+      "engines": {
+        "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+      }
+    },
+    "node_modules/debug": {
+      "version": "4.4.3",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+      "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+      "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/decode-named-character-reference": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
+      "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==",
+      "license": "MIT",
+      "dependencies": {
+        "character-entities": "^2.0.0"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/wooorm"
+      }
+    },
+    "node_modules/dequal": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
+      "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
+      "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",
@@ -1236,6 +2487,33 @@
         "node": ">=8"
       }
     },
+    "node_modules/devlop": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
+      "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==",
+      "license": "MIT",
+      "dependencies": {
+        "dequal": "^2.0.0"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/wooorm"
+      }
+    },
+    "node_modules/dom-accessibility-api": {
+      "version": "0.5.16",
+      "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
+      "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/electron-to-chromium": {
+      "version": "1.5.394",
+      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.394.tgz",
+      "integrity": "sha512-Wmt2Gm0o8JWBuGgmc4XZ0u9s1RaCRqhxP47phplmfg04+qypTUurpeJGP45A7Fhv7jdrrVH44PLlR9qXo37cVQ==",
+      "dev": true,
+      "license": "ISC"
+    },
     "node_modules/enhanced-resolve": {
       "version": "5.24.2",
       "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.2.tgz",
@@ -1249,46 +2527,100 @@
         "node": ">=10.13.0"
       }
     },
-    "node_modules/esbuild": {
-      "version": "0.28.1",
-      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
-      "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
+    "node_modules/entities": {
+      "version": "8.0.0",
+      "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
+      "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
       "dev": true,
-      "hasInstallScript": true,
-      "license": "MIT",
-      "bin": {
-        "esbuild": "bin/esbuild"
-      },
+      "license": "BSD-2-Clause",
       "engines": {
-        "node": ">=18"
+        "node": ">=20.19.0"
       },
-      "optionalDependencies": {
-        "@esbuild/aix-ppc64": "0.28.1",
-        "@esbuild/android-arm": "0.28.1",
-        "@esbuild/android-arm64": "0.28.1",
-        "@esbuild/android-x64": "0.28.1",
-        "@esbuild/darwin-arm64": "0.28.1",
-        "@esbuild/darwin-x64": "0.28.1",
-        "@esbuild/freebsd-arm64": "0.28.1",
-        "@esbuild/freebsd-x64": "0.28.1",
-        "@esbuild/linux-arm": "0.28.1",
-        "@esbuild/linux-arm64": "0.28.1",
-        "@esbuild/linux-ia32": "0.28.1",
-        "@esbuild/linux-loong64": "0.28.1",
-        "@esbuild/linux-mips64el": "0.28.1",
-        "@esbuild/linux-ppc64": "0.28.1",
-        "@esbuild/linux-riscv64": "0.28.1",
-        "@esbuild/linux-s390x": "0.28.1",
-        "@esbuild/linux-x64": "0.28.1",
-        "@esbuild/netbsd-arm64": "0.28.1",
-        "@esbuild/netbsd-x64": "0.28.1",
-        "@esbuild/openbsd-arm64": "0.28.1",
-        "@esbuild/openbsd-x64": "0.28.1",
-        "@esbuild/openharmony-arm64": "0.28.1",
-        "@esbuild/sunos-x64": "0.28.1",
-        "@esbuild/win32-arm64": "0.28.1",
-        "@esbuild/win32-ia32": "0.28.1",
-        "@esbuild/win32-x64": "0.28.1"
+      "funding": {
+        "url": "https://github.com/fb55/entities?sponsor=1"
+      }
+    },
+    "node_modules/es-module-lexer": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz",
+      "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/escalade": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+      "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/escape-string-regexp": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
+      "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/estree-util-is-identifier-name": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz",
+      "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==",
+      "license": "MIT",
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/estree-walker": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+      "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree": "^1.0.0"
+      }
+    },
+    "node_modules/expect-type": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
+      "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=12.0.0"
+      }
+    },
+    "node_modules/extend": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
+      "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
+      "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/fill-range": {
@@ -1303,12 +2635,244 @@
         "node": ">=8"
       }
     },
+    "node_modules/fsevents": {
+      "version": "2.3.3",
+      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/gensync": {
+      "version": "1.0.0-beta.2",
+      "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+      "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/github-slugger": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz",
+      "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==",
+      "license": "ISC"
+    },
     "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==",
       "license": "ISC"
     },
+    "node_modules/hast-util-heading-rank": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/hast-util-heading-rank/-/hast-util-heading-rank-3.0.0.tgz",
+      "integrity": "sha512-EJKb8oMUXVHcWZTDepnr+WNbfnXKFNf9duMesmr4S8SXTJBJ9M4Yok08pu9vxdJwdlGRhVumk9mEhkEvKGifwA==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/hast": "^3.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/hast-util-is-element": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz",
+      "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/hast": "^3.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/hast-util-to-jsx-runtime": {
+      "version": "2.3.6",
+      "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz",
+      "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree": "^1.0.0",
+        "@types/hast": "^3.0.0",
+        "@types/unist": "^3.0.0",
+        "comma-separated-tokens": "^2.0.0",
+        "devlop": "^1.0.0",
+        "estree-util-is-identifier-name": "^3.0.0",
+        "hast-util-whitespace": "^3.0.0",
+        "mdast-util-mdx-expression": "^2.0.0",
+        "mdast-util-mdx-jsx": "^3.0.0",
+        "mdast-util-mdxjs-esm": "^2.0.0",
+        "property-information": "^7.0.0",
+        "space-separated-tokens": "^2.0.0",
+        "style-to-js": "^1.0.0",
+        "unist-util-position": "^5.0.0",
+        "vfile-message": "^4.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/hast-util-to-string": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/hast-util-to-string/-/hast-util-to-string-3.0.1.tgz",
+      "integrity": "sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/hast": "^3.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/hast-util-whitespace": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz",
+      "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/hast": "^3.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/html-encoding-sniffer": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
+      "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@exodus/bytes": "^1.6.0"
+      },
+      "engines": {
+        "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+      }
+    },
+    "node_modules/html-parse-stringify": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
+      "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
+      "license": "MIT",
+      "dependencies": {
+        "void-elements": "3.1.0"
+      }
+    },
+    "node_modules/html-url-attributes": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
+      "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==",
+      "license": "MIT",
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/i18next": {
+      "version": "25.10.10",
+      "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.10.10.tgz",
+      "integrity": "sha512-cqUW2Z3EkRx7NqSyywjkgCLK7KLCL6IFVFcONG7nVYIJ3ekZ1/N5jUsihHV6Bq37NfhgtczxJcxduELtjTwkuQ==",
+      "funding": [
+        {
+          "type": "individual",
+          "url": "https://www.locize.com/i18next"
+        },
+        {
+          "type": "individual",
+          "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
+        },
+        {
+          "type": "individual",
+          "url": "https://www.locize.com"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.29.2"
+      },
+      "peerDependencies": {
+        "typescript": "^5 || ^6"
+      },
+      "peerDependenciesMeta": {
+        "typescript": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/i18next-browser-languagedetector": {
+      "version": "8.2.1",
+      "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz",
+      "integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.23.2"
+      }
+    },
+    "node_modules/indent-string": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
+      "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/inline-style-parser": {
+      "version": "0.2.7",
+      "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
+      "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==",
+      "license": "MIT"
+    },
+    "node_modules/is-alphabetical": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
+      "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/wooorm"
+      }
+    },
+    "node_modules/is-alphanumerical": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz",
+      "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==",
+      "license": "MIT",
+      "dependencies": {
+        "is-alphabetical": "^2.0.0",
+        "is-decimal": "^2.0.0"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/wooorm"
+      }
+    },
+    "node_modules/is-decimal": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz",
+      "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/wooorm"
+      }
+    },
     "node_modules/is-extglob": {
       "version": "2.1.1",
       "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -1330,6 +2894,16 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/is-hexadecimal": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz",
+      "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/wooorm"
+      }
+    },
     "node_modules/is-number": {
       "version": "7.0.0",
       "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
@@ -1339,6 +2913,25 @@
         "node": ">=0.12.0"
       }
     },
+    "node_modules/is-plain-obj": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz",
+      "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/is-potential-custom-element-name": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
+      "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/jiti": {
       "version": "2.7.0",
       "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
@@ -1348,6 +2941,89 @@
         "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==",
+      "license": "MIT"
+    },
+    "node_modules/jsdom": {
+      "version": "29.1.1",
+      "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
+      "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@asamuzakjp/css-color": "^5.1.11",
+        "@asamuzakjp/dom-selector": "^7.1.1",
+        "@bramus/specificity": "^2.4.2",
+        "@csstools/css-syntax-patches-for-csstree": "^1.1.3",
+        "@exodus/bytes": "^1.15.0",
+        "css-tree": "^3.2.1",
+        "data-urls": "^7.0.0",
+        "decimal.js": "^10.6.0",
+        "html-encoding-sniffer": "^6.0.0",
+        "is-potential-custom-element-name": "^1.0.1",
+        "lru-cache": "^11.3.5",
+        "parse5": "^8.0.1",
+        "saxes": "^6.0.0",
+        "symbol-tree": "^3.2.4",
+        "tough-cookie": "^6.0.1",
+        "undici": "^7.25.0",
+        "w3c-xmlserializer": "^5.0.0",
+        "webidl-conversions": "^8.0.1",
+        "whatwg-mimetype": "^5.0.0",
+        "whatwg-url": "^16.0.1",
+        "xml-name-validator": "^5.0.0"
+      },
+      "engines": {
+        "node": "^20.19.0 || ^22.13.0 || >=24.0.0"
+      },
+      "peerDependencies": {
+        "canvas": "^3.0.0"
+      },
+      "peerDependenciesMeta": {
+        "canvas": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/jsdom/node_modules/lru-cache": {
+      "version": "11.5.2",
+      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
+      "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
+      "dev": true,
+      "license": "BlueOak-1.0.0",
+      "engines": {
+        "node": "20 || >=22"
+      }
+    },
+    "node_modules/jsesc": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+      "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+      "dev": true,
+      "license": "MIT",
+      "bin": {
+        "jsesc": "bin/jsesc"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/json5": {
+      "version": "2.2.3",
+      "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+      "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+      "dev": true,
+      "license": "MIT",
+      "bin": {
+        "json5": "lib/cli.js"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
     "node_modules/leaflet": {
       "version": "1.9.4",
       "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
@@ -1603,13 +3279,46 @@
         "url": "https://opencollective.com/parcel"
       }
     },
-    "node_modules/lit-html": {
-      "version": "3.3.3",
-      "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-3.3.3.tgz",
-      "integrity": "sha512-el8M6jK2o3RXBnrSHX3ZKrsN8zEV63pSExTO1wYJz7QndGYZ8353e2a5PPX+qHe2aGayfnchQmkAojaWAREOIA==",
-      "license": "BSD-3-Clause",
+    "node_modules/longest-streak": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz",
+      "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/wooorm"
+      }
+    },
+    "node_modules/loose-envify": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+      "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+      "license": "MIT",
       "dependencies": {
-        "@types/trusted-types": "^2.0.2"
+        "js-tokens": "^3.0.0 || ^4.0.0"
+      },
+      "bin": {
+        "loose-envify": "cli.js"
+      }
+    },
+    "node_modules/lru-cache": {
+      "version": "5.1.1",
+      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+      "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "yallist": "^3.0.2"
+      }
+    },
+    "node_modules/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",
+      "bin": {
+        "lz-string": "bin/bin.js"
       }
     },
     "node_modules/magic-string": {
@@ -1621,6 +3330,856 @@
         "@jridgewell/sourcemap-codec": "^1.5.5"
       }
     },
+    "node_modules/markdown-table": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz",
+      "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/wooorm"
+      }
+    },
+    "node_modules/mdast-util-find-and-replace": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz",
+      "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/mdast": "^4.0.0",
+        "escape-string-regexp": "^5.0.0",
+        "unist-util-is": "^6.0.0",
+        "unist-util-visit-parents": "^6.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/mdast-util-from-markdown": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz",
+      "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/mdast": "^4.0.0",
+        "@types/unist": "^3.0.0",
+        "decode-named-character-reference": "^1.0.0",
+        "devlop": "^1.0.0",
+        "mdast-util-to-string": "^4.0.0",
+        "micromark": "^4.0.0",
+        "micromark-util-decode-numeric-character-reference": "^2.0.0",
+        "micromark-util-decode-string": "^2.0.0",
+        "micromark-util-normalize-identifier": "^2.0.0",
+        "micromark-util-symbol": "^2.0.0",
+        "micromark-util-types": "^2.0.0",
+        "unist-util-stringify-position": "^4.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/mdast-util-gfm": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz",
+      "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==",
+      "license": "MIT",
+      "dependencies": {
+        "mdast-util-from-markdown": "^2.0.0",
+        "mdast-util-gfm-autolink-literal": "^2.0.0",
+        "mdast-util-gfm-footnote": "^2.0.0",
+        "mdast-util-gfm-strikethrough": "^2.0.0",
+        "mdast-util-gfm-table": "^2.0.0",
+        "mdast-util-gfm-task-list-item": "^2.0.0",
+        "mdast-util-to-markdown": "^2.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/mdast-util-gfm-autolink-literal": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz",
+      "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/mdast": "^4.0.0",
+        "ccount": "^2.0.0",
+        "devlop": "^1.0.0",
+        "mdast-util-find-and-replace": "^3.0.0",
+        "micromark-util-character": "^2.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/mdast-util-gfm-footnote": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz",
+      "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/mdast": "^4.0.0",
+        "devlop": "^1.1.0",
+        "mdast-util-from-markdown": "^2.0.0",
+        "mdast-util-to-markdown": "^2.0.0",
+        "micromark-util-normalize-identifier": "^2.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/mdast-util-gfm-strikethrough": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz",
+      "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/mdast": "^4.0.0",
+        "mdast-util-from-markdown": "^2.0.0",
+        "mdast-util-to-markdown": "^2.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/mdast-util-gfm-table": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz",
+      "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/mdast": "^4.0.0",
+        "devlop": "^1.0.0",
+        "markdown-table": "^3.0.0",
+        "mdast-util-from-markdown": "^2.0.0",
+        "mdast-util-to-markdown": "^2.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/mdast-util-gfm-task-list-item": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz",
+      "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/mdast": "^4.0.0",
+        "devlop": "^1.0.0",
+        "mdast-util-from-markdown": "^2.0.0",
+        "mdast-util-to-markdown": "^2.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/mdast-util-mdx-expression": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz",
+      "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree-jsx": "^1.0.0",
+        "@types/hast": "^3.0.0",
+        "@types/mdast": "^4.0.0",
+        "devlop": "^1.0.0",
+        "mdast-util-from-markdown": "^2.0.0",
+        "mdast-util-to-markdown": "^2.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/mdast-util-mdx-jsx": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz",
+      "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree-jsx": "^1.0.0",
+        "@types/hast": "^3.0.0",
+        "@types/mdast": "^4.0.0",
+        "@types/unist": "^3.0.0",
+        "ccount": "^2.0.0",
+        "devlop": "^1.1.0",
+        "mdast-util-from-markdown": "^2.0.0",
+        "mdast-util-to-markdown": "^2.0.0",
+        "parse-entities": "^4.0.0",
+        "stringify-entities": "^4.0.0",
+        "unist-util-stringify-position": "^4.0.0",
+        "vfile-message": "^4.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/mdast-util-mdxjs-esm": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz",
+      "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree-jsx": "^1.0.0",
+        "@types/hast": "^3.0.0",
+        "@types/mdast": "^4.0.0",
+        "devlop": "^1.0.0",
+        "mdast-util-from-markdown": "^2.0.0",
+        "mdast-util-to-markdown": "^2.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/mdast-util-phrasing": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz",
+      "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/mdast": "^4.0.0",
+        "unist-util-is": "^6.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/mdast-util-to-hast": {
+      "version": "13.2.1",
+      "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz",
+      "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/hast": "^3.0.0",
+        "@types/mdast": "^4.0.0",
+        "@ungap/structured-clone": "^1.0.0",
+        "devlop": "^1.0.0",
+        "micromark-util-sanitize-uri": "^2.0.0",
+        "trim-lines": "^3.0.0",
+        "unist-util-position": "^5.0.0",
+        "unist-util-visit": "^5.0.0",
+        "vfile": "^6.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/mdast-util-to-markdown": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz",
+      "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/mdast": "^4.0.0",
+        "@types/unist": "^3.0.0",
+        "longest-streak": "^3.0.0",
+        "mdast-util-phrasing": "^4.0.0",
+        "mdast-util-to-string": "^4.0.0",
+        "micromark-util-classify-character": "^2.0.0",
+        "micromark-util-decode-string": "^2.0.0",
+        "unist-util-visit": "^5.0.0",
+        "zwitch": "^2.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/mdast-util-to-string": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz",
+      "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/mdast": "^4.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/mdn-data": {
+      "version": "2.27.1",
+      "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
+      "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
+      "dev": true,
+      "license": "CC0-1.0"
+    },
+    "node_modules/micromark": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz",
+      "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==",
+      "funding": [
+        {
+          "type": "GitHub Sponsors",
+          "url": "https://github.com/sponsors/unifiedjs"
+        },
+        {
+          "type": "OpenCollective",
+          "url": "https://opencollective.com/unified"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "@types/debug": "^4.0.0",
+        "debug": "^4.0.0",
+        "decode-named-character-reference": "^1.0.0",
+        "devlop": "^1.0.0",
+        "micromark-core-commonmark": "^2.0.0",
+        "micromark-factory-space": "^2.0.0",
+        "micromark-util-character": "^2.0.0",
+        "micromark-util-chunked": "^2.0.0",
+        "micromark-util-combine-extensions": "^2.0.0",
+        "micromark-util-decode-numeric-character-reference": "^2.0.0",
+        "micromark-util-encode": "^2.0.0",
+        "micromark-util-normalize-identifier": "^2.0.0",
+        "micromark-util-resolve-all": "^2.0.0",
+        "micromark-util-sanitize-uri": "^2.0.0",
+        "micromark-util-subtokenize": "^2.0.0",
+        "micromark-util-symbol": "^2.0.0",
+        "micromark-util-types": "^2.0.0"
+      }
+    },
+    "node_modules/micromark-core-commonmark": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz",
+      "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==",
+      "funding": [
+        {
+          "type": "GitHub Sponsors",
+          "url": "https://github.com/sponsors/unifiedjs"
+        },
+        {
+          "type": "OpenCollective",
+          "url": "https://opencollective.com/unified"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "decode-named-character-reference": "^1.0.0",
+        "devlop": "^1.0.0",
+        "micromark-factory-destination": "^2.0.0",
+        "micromark-factory-label": "^2.0.0",
+        "micromark-factory-space": "^2.0.0",
+        "micromark-factory-title": "^2.0.0",
+        "micromark-factory-whitespace": "^2.0.0",
+        "micromark-util-character": "^2.0.0",
+        "micromark-util-chunked": "^2.0.0",
+        "micromark-util-classify-character": "^2.0.0",
+        "micromark-util-html-tag-name": "^2.0.0",
+        "micromark-util-normalize-identifier": "^2.0.0",
+        "micromark-util-resolve-all": "^2.0.0",
+        "micromark-util-subtokenize": "^2.0.0",
+        "micromark-util-symbol": "^2.0.0",
+        "micromark-util-types": "^2.0.0"
+      }
+    },
+    "node_modules/micromark-extension-gfm": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz",
+      "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==",
+      "license": "MIT",
+      "dependencies": {
+        "micromark-extension-gfm-autolink-literal": "^2.0.0",
+        "micromark-extension-gfm-footnote": "^2.0.0",
+        "micromark-extension-gfm-strikethrough": "^2.0.0",
+        "micromark-extension-gfm-table": "^2.0.0",
+        "micromark-extension-gfm-tagfilter": "^2.0.0",
+        "micromark-extension-gfm-task-list-item": "^2.0.0",
+        "micromark-util-combine-extensions": "^2.0.0",
+        "micromark-util-types": "^2.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/micromark-extension-gfm-autolink-literal": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz",
+      "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==",
+      "license": "MIT",
+      "dependencies": {
+        "micromark-util-character": "^2.0.0",
+        "micromark-util-sanitize-uri": "^2.0.0",
+        "micromark-util-symbol": "^2.0.0",
+        "micromark-util-types": "^2.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/micromark-extension-gfm-footnote": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz",
+      "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==",
+      "license": "MIT",
+      "dependencies": {
+        "devlop": "^1.0.0",
+        "micromark-core-commonmark": "^2.0.0",
+        "micromark-factory-space": "^2.0.0",
+        "micromark-util-character": "^2.0.0",
+        "micromark-util-normalize-identifier": "^2.0.0",
+        "micromark-util-sanitize-uri": "^2.0.0",
+        "micromark-util-symbol": "^2.0.0",
+        "micromark-util-types": "^2.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/micromark-extension-gfm-strikethrough": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz",
+      "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==",
+      "license": "MIT",
+      "dependencies": {
+        "devlop": "^1.0.0",
+        "micromark-util-chunked": "^2.0.0",
+        "micromark-util-classify-character": "^2.0.0",
+        "micromark-util-resolve-all": "^2.0.0",
+        "micromark-util-symbol": "^2.0.0",
+        "micromark-util-types": "^2.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/micromark-extension-gfm-table": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz",
+      "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==",
+      "license": "MIT",
+      "dependencies": {
+        "devlop": "^1.0.0",
+        "micromark-factory-space": "^2.0.0",
+        "micromark-util-character": "^2.0.0",
+        "micromark-util-symbol": "^2.0.0",
+        "micromark-util-types": "^2.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/micromark-extension-gfm-tagfilter": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz",
+      "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==",
+      "license": "MIT",
+      "dependencies": {
+        "micromark-util-types": "^2.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/micromark-extension-gfm-task-list-item": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz",
+      "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==",
+      "license": "MIT",
+      "dependencies": {
+        "devlop": "^1.0.0",
+        "micromark-factory-space": "^2.0.0",
+        "micromark-util-character": "^2.0.0",
+        "micromark-util-symbol": "^2.0.0",
+        "micromark-util-types": "^2.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/micromark-factory-destination": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz",
+      "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==",
+      "funding": [
+        {
+          "type": "GitHub Sponsors",
+          "url": "https://github.com/sponsors/unifiedjs"
+        },
+        {
+          "type": "OpenCollective",
+          "url": "https://opencollective.com/unified"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "micromark-util-character": "^2.0.0",
+        "micromark-util-symbol": "^2.0.0",
+        "micromark-util-types": "^2.0.0"
+      }
+    },
+    "node_modules/micromark-factory-label": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz",
+      "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==",
+      "funding": [
+        {
+          "type": "GitHub Sponsors",
+          "url": "https://github.com/sponsors/unifiedjs"
+        },
+        {
+          "type": "OpenCollective",
+          "url": "https://opencollective.com/unified"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "devlop": "^1.0.0",
+        "micromark-util-character": "^2.0.0",
+        "micromark-util-symbol": "^2.0.0",
+        "micromark-util-types": "^2.0.0"
+      }
+    },
+    "node_modules/micromark-factory-space": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz",
+      "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==",
+      "funding": [
+        {
+          "type": "GitHub Sponsors",
+          "url": "https://github.com/sponsors/unifiedjs"
+        },
+        {
+          "type": "OpenCollective",
+          "url": "https://opencollective.com/unified"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "micromark-util-character": "^2.0.0",
+        "micromark-util-types": "^2.0.0"
+      }
+    },
+    "node_modules/micromark-factory-title": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz",
+      "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==",
+      "funding": [
+        {
+          "type": "GitHub Sponsors",
+          "url": "https://github.com/sponsors/unifiedjs"
+        },
+        {
+          "type": "OpenCollective",
+          "url": "https://opencollective.com/unified"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "micromark-factory-space": "^2.0.0",
+        "micromark-util-character": "^2.0.0",
+        "micromark-util-symbol": "^2.0.0",
+        "micromark-util-types": "^2.0.0"
+      }
+    },
+    "node_modules/micromark-factory-whitespace": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz",
+      "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==",
+      "funding": [
+        {
+          "type": "GitHub Sponsors",
+          "url": "https://github.com/sponsors/unifiedjs"
+        },
+        {
+          "type": "OpenCollective",
+          "url": "https://opencollective.com/unified"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "micromark-factory-space": "^2.0.0",
+        "micromark-util-character": "^2.0.0",
+        "micromark-util-symbol": "^2.0.0",
+        "micromark-util-types": "^2.0.0"
+      }
+    },
+    "node_modules/micromark-util-character": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz",
+      "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==",
+      "funding": [
+        {
+          "type": "GitHub Sponsors",
+          "url": "https://github.com/sponsors/unifiedjs"
+        },
+        {
+          "type": "OpenCollective",
+          "url": "https://opencollective.com/unified"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "micromark-util-symbol": "^2.0.0",
+        "micromark-util-types": "^2.0.0"
+      }
+    },
+    "node_modules/micromark-util-chunked": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz",
+      "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==",
+      "funding": [
+        {
+          "type": "GitHub Sponsors",
+          "url": "https://github.com/sponsors/unifiedjs"
+        },
+        {
+          "type": "OpenCollective",
+          "url": "https://opencollective.com/unified"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "micromark-util-symbol": "^2.0.0"
+      }
+    },
+    "node_modules/micromark-util-classify-character": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz",
+      "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==",
+      "funding": [
+        {
+          "type": "GitHub Sponsors",
+          "url": "https://github.com/sponsors/unifiedjs"
+        },
+        {
+          "type": "OpenCollective",
+          "url": "https://opencollective.com/unified"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "micromark-util-character": "^2.0.0",
+        "micromark-util-symbol": "^2.0.0",
+        "micromark-util-types": "^2.0.0"
+      }
+    },
+    "node_modules/micromark-util-combine-extensions": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz",
+      "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==",
+      "funding": [
+        {
+          "type": "GitHub Sponsors",
+          "url": "https://github.com/sponsors/unifiedjs"
+        },
+        {
+          "type": "OpenCollective",
+          "url": "https://opencollective.com/unified"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "micromark-util-chunked": "^2.0.0",
+        "micromark-util-types": "^2.0.0"
+      }
+    },
+    "node_modules/micromark-util-decode-numeric-character-reference": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz",
+      "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==",
+      "funding": [
+        {
+          "type": "GitHub Sponsors",
+          "url": "https://github.com/sponsors/unifiedjs"
+        },
+        {
+          "type": "OpenCollective",
+          "url": "https://opencollective.com/unified"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "micromark-util-symbol": "^2.0.0"
+      }
+    },
+    "node_modules/micromark-util-decode-string": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz",
+      "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==",
+      "funding": [
+        {
+          "type": "GitHub Sponsors",
+          "url": "https://github.com/sponsors/unifiedjs"
+        },
+        {
+          "type": "OpenCollective",
+          "url": "https://opencollective.com/unified"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "decode-named-character-reference": "^1.0.0",
+        "micromark-util-character": "^2.0.0",
+        "micromark-util-decode-numeric-character-reference": "^2.0.0",
+        "micromark-util-symbol": "^2.0.0"
+      }
+    },
+    "node_modules/micromark-util-encode": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz",
+      "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==",
+      "funding": [
+        {
+          "type": "GitHub Sponsors",
+          "url": "https://github.com/sponsors/unifiedjs"
+        },
+        {
+          "type": "OpenCollective",
+          "url": "https://opencollective.com/unified"
+        }
+      ],
+      "license": "MIT"
+    },
+    "node_modules/micromark-util-html-tag-name": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz",
+      "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==",
+      "funding": [
+        {
+          "type": "GitHub Sponsors",
+          "url": "https://github.com/sponsors/unifiedjs"
+        },
+        {
+          "type": "OpenCollective",
+          "url": "https://opencollective.com/unified"
+        }
+      ],
+      "license": "MIT"
+    },
+    "node_modules/micromark-util-normalize-identifier": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz",
+      "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==",
+      "funding": [
+        {
+          "type": "GitHub Sponsors",
+          "url": "https://github.com/sponsors/unifiedjs"
+        },
+        {
+          "type": "OpenCollective",
+          "url": "https://opencollective.com/unified"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "micromark-util-symbol": "^2.0.0"
+      }
+    },
+    "node_modules/micromark-util-resolve-all": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz",
+      "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==",
+      "funding": [
+        {
+          "type": "GitHub Sponsors",
+          "url": "https://github.com/sponsors/unifiedjs"
+        },
+        {
+          "type": "OpenCollective",
+          "url": "https://opencollective.com/unified"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "micromark-util-types": "^2.0.0"
+      }
+    },
+    "node_modules/micromark-util-sanitize-uri": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz",
+      "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==",
+      "funding": [
+        {
+          "type": "GitHub Sponsors",
+          "url": "https://github.com/sponsors/unifiedjs"
+        },
+        {
+          "type": "OpenCollective",
+          "url": "https://opencollective.com/unified"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "micromark-util-character": "^2.0.0",
+        "micromark-util-encode": "^2.0.0",
+        "micromark-util-symbol": "^2.0.0"
+      }
+    },
+    "node_modules/micromark-util-subtokenize": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz",
+      "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==",
+      "funding": [
+        {
+          "type": "GitHub Sponsors",
+          "url": "https://github.com/sponsors/unifiedjs"
+        },
+        {
+          "type": "OpenCollective",
+          "url": "https://opencollective.com/unified"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "devlop": "^1.0.0",
+        "micromark-util-chunked": "^2.0.0",
+        "micromark-util-symbol": "^2.0.0",
+        "micromark-util-types": "^2.0.0"
+      }
+    },
+    "node_modules/micromark-util-symbol": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz",
+      "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==",
+      "funding": [
+        {
+          "type": "GitHub Sponsors",
+          "url": "https://github.com/sponsors/unifiedjs"
+        },
+        {
+          "type": "OpenCollective",
+          "url": "https://opencollective.com/unified"
+        }
+      ],
+      "license": "MIT"
+    },
+    "node_modules/micromark-util-types": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz",
+      "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==",
+      "funding": [
+        {
+          "type": "GitHub Sponsors",
+          "url": "https://github.com/sponsors/unifiedjs"
+        },
+        {
+          "type": "OpenCollective",
+          "url": "https://opencollective.com/unified"
+        }
+      ],
+      "license": "MIT"
+    },
     "node_modules/micromatch": {
       "version": "4.0.8",
       "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
@@ -1646,6 +4205,16 @@
         "url": "https://github.com/sponsors/jonschlinkert"
       }
     },
+    "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/mri": {
       "version": "1.2.0",
       "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
@@ -1655,22 +4224,629 @@
         "node": ">=4"
       }
     },
+    "node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "license": "MIT"
+    },
+    "node_modules/nanoid": {
+      "version": "3.3.16",
+      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
+      "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "bin": {
+        "nanoid": "bin/nanoid.cjs"
+      },
+      "engines": {
+        "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+      }
+    },
     "node_modules/node-addon-api": {
       "version": "7.1.1",
       "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
       "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
       "license": "MIT"
     },
+    "node_modules/node-releases": {
+      "version": "2.0.51",
+      "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz",
+      "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/object-assign": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+      "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/obug": {
+      "version": "2.1.4",
+      "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz",
+      "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==",
+      "dev": true,
+      "funding": [
+        "https://github.com/sponsors/sxzz",
+        "https://opencollective.com/debug"
+      ],
+      "license": "MIT",
+      "engines": {
+        "node": ">=12.20.0"
+      }
+    },
+    "node_modules/parse-entities": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz",
+      "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/unist": "^2.0.0",
+        "character-entities-legacy": "^3.0.0",
+        "character-reference-invalid": "^2.0.0",
+        "decode-named-character-reference": "^1.0.0",
+        "is-alphanumerical": "^2.0.0",
+        "is-decimal": "^2.0.0",
+        "is-hexadecimal": "^2.0.0"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/wooorm"
+      }
+    },
+    "node_modules/parse-entities/node_modules/@types/unist": {
+      "version": "2.0.11",
+      "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz",
+      "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
+      "license": "MIT"
+    },
+    "node_modules/parse5": {
+      "version": "8.0.1",
+      "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
+      "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "entities": "^8.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/inikulin/parse5?sponsor=1"
+      }
+    },
+    "node_modules/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==",
       "license": "ISC"
     },
-    "node_modules/qrcodejs": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/qrcodejs/-/qrcodejs-1.0.0.tgz",
-      "integrity": "sha512-67rj3mMBhSBepaD57qENnltO+r8rSYlqM7HGThks/BiyDAkc86sLvkKqjkqPS5v13f7tvnt6dbEf3qt7zq+BCg=="
+    "node_modules/picomatch": {
+      "version": "4.0.5",
+      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
+      "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/jonschlinkert"
+      }
+    },
+    "node_modules/playwright": {
+      "version": "1.61.1",
+      "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
+      "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "playwright-core": "1.61.1"
+      },
+      "bin": {
+        "playwright": "cli.js"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "optionalDependencies": {
+        "fsevents": "2.3.2"
+      }
+    },
+    "node_modules/playwright-core": {
+      "version": "1.61.1",
+      "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
+      "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
+      "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.21",
+      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.21.tgz",
+      "integrity": "sha512-v4sDNP3fdNiWMfabO7OwOQdOX8TiQSztKyT1Wj0w+j7LDallJThJRBBBmzVGyYj0crMh7jlV4zepPkiNu9UwDQ==",
+      "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.16",
+        "picocolors": "^1.1.1",
+        "source-map-js": "^1.2.1"
+      },
+      "engines": {
+        "node": "^10 || ^12 || >=14"
+      }
+    },
+    "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",
+      "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/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"
+    },
+    "node_modules/prop-types": {
+      "version": "15.8.1",
+      "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
+      "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
+      "license": "MIT",
+      "dependencies": {
+        "loose-envify": "^1.4.0",
+        "object-assign": "^4.1.1",
+        "react-is": "^16.13.1"
+      }
+    },
+    "node_modules/property-information": {
+      "version": "7.2.0",
+      "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz",
+      "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/wooorm"
+      }
+    },
+    "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/qrcode-generator": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-2.0.4.tgz",
+      "integrity": "sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g==",
+      "license": "MIT"
+    },
+    "node_modules/react": {
+      "version": "19.2.7",
+      "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
+      "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/react-chartjs-2": {
+      "version": "5.3.1",
+      "resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.3.1.tgz",
+      "integrity": "sha512-h5IPXKg9EXpjoBzUfyWJvllMjG2mQ4EiuHQFhms/AjUm0XSZHhyRy2xVmLXHKrtcdrPO4mnGqRtYoD0vp95A0A==",
+      "license": "MIT",
+      "peerDependencies": {
+        "chart.js": "^4.1.1",
+        "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+      }
+    },
+    "node_modules/react-dom": {
+      "version": "19.2.7",
+      "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
+      "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
+      "license": "MIT",
+      "dependencies": {
+        "scheduler": "^0.27.0"
+      },
+      "peerDependencies": {
+        "react": "^19.2.7"
+      }
+    },
+    "node_modules/react-i18next": {
+      "version": "15.7.4",
+      "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.7.4.tgz",
+      "integrity": "sha512-nyU8iKNrI5uDJch0z9+Y5XEr34b0wkyYj3Rp+tfbahxtlswxSCjcUL9H0nqXo9IR3/t5Y5PKIA3fx3MfUyR9Xw==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.27.6",
+        "html-parse-stringify": "^3.0.1"
+      },
+      "peerDependencies": {
+        "i18next": ">= 23.4.0",
+        "react": ">= 16.8.0",
+        "typescript": "^5"
+      },
+      "peerDependenciesMeta": {
+        "react-dom": {
+          "optional": true
+        },
+        "react-native": {
+          "optional": true
+        },
+        "typescript": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/react-is": {
+      "version": "16.13.1",
+      "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+      "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
+      "license": "MIT"
+    },
+    "node_modules/react-leaflet": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz",
+      "integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==",
+      "license": "Hippocratic-2.1",
+      "dependencies": {
+        "@react-leaflet/core": "^3.0.0"
+      },
+      "peerDependencies": {
+        "leaflet": "^1.9.0",
+        "react": "^19.0.0",
+        "react-dom": "^19.0.0"
+      }
+    },
+    "node_modules/react-markdown": {
+      "version": "10.1.0",
+      "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
+      "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/hast": "^3.0.0",
+        "@types/mdast": "^4.0.0",
+        "devlop": "^1.0.0",
+        "hast-util-to-jsx-runtime": "^2.0.0",
+        "html-url-attributes": "^3.0.0",
+        "mdast-util-to-hast": "^13.0.0",
+        "remark-parse": "^11.0.0",
+        "remark-rehype": "^11.0.0",
+        "unified": "^11.0.0",
+        "unist-util-visit": "^5.0.0",
+        "vfile": "^6.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      },
+      "peerDependencies": {
+        "@types/react": ">=18",
+        "react": ">=18"
+      }
+    },
+    "node_modules/react-qr-code": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/react-qr-code/-/react-qr-code-2.2.0.tgz",
+      "integrity": "sha512-e5nS0UUN22K3Nf8KBRUzemfdJ6OmnN5w+kbnj1lvJaol9RyVRFeGl05bCkxSN2ZegbLxjjYjX1+mmAoX9+fAhw==",
+      "license": "MIT",
+      "dependencies": {
+        "prop-types": "^15.8.1",
+        "qrcode-generator": "^2.0.4"
+      },
+      "peerDependencies": {
+        "react": "*"
+      }
+    },
+    "node_modules/react-refresh": {
+      "version": "0.17.0",
+      "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
+      "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/react-router": {
+      "version": "7.18.1",
+      "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz",
+      "integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==",
+      "license": "MIT",
+      "dependencies": {
+        "cookie": "^1.0.1",
+        "set-cookie-parser": "^2.6.0"
+      },
+      "engines": {
+        "node": ">=20.0.0"
+      },
+      "peerDependencies": {
+        "react": ">=18",
+        "react-dom": ">=18"
+      },
+      "peerDependenciesMeta": {
+        "react-dom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/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/rehype-autolink-headings": {
+      "version": "7.1.0",
+      "resolved": "https://registry.npmjs.org/rehype-autolink-headings/-/rehype-autolink-headings-7.1.0.tgz",
+      "integrity": "sha512-rItO/pSdvnvsP4QRB1pmPiNHUskikqtPojZKJPPPAVx9Hj8i8TwMBhofrrAYRhYOOBZH9tgmG5lPqDLuIWPWmw==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/hast": "^3.0.0",
+        "@ungap/structured-clone": "^1.0.0",
+        "hast-util-heading-rank": "^3.0.0",
+        "hast-util-is-element": "^3.0.0",
+        "unified": "^11.0.0",
+        "unist-util-visit": "^5.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/rehype-slug": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/rehype-slug/-/rehype-slug-6.0.0.tgz",
+      "integrity": "sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/hast": "^3.0.0",
+        "github-slugger": "^2.0.0",
+        "hast-util-heading-rank": "^3.0.0",
+        "hast-util-to-string": "^3.0.0",
+        "unist-util-visit": "^5.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/remark-gfm": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
+      "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/mdast": "^4.0.0",
+        "mdast-util-gfm": "^3.0.0",
+        "micromark-extension-gfm": "^3.0.0",
+        "remark-parse": "^11.0.0",
+        "remark-stringify": "^11.0.0",
+        "unified": "^11.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/remark-parse": {
+      "version": "11.0.0",
+      "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz",
+      "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/mdast": "^4.0.0",
+        "mdast-util-from-markdown": "^2.0.0",
+        "micromark-util-types": "^2.0.0",
+        "unified": "^11.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/remark-rehype": {
+      "version": "11.1.2",
+      "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz",
+      "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/hast": "^3.0.0",
+        "@types/mdast": "^4.0.0",
+        "mdast-util-to-hast": "^13.0.0",
+        "unified": "^11.0.0",
+        "vfile": "^6.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/remark-stringify": {
+      "version": "11.0.0",
+      "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz",
+      "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/mdast": "^4.0.0",
+        "mdast-util-to-markdown": "^2.0.0",
+        "unified": "^11.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/require-from-string": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+      "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/rollup": {
+      "version": "4.62.2",
+      "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
+      "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree": "1.0.9"
+      },
+      "bin": {
+        "rollup": "dist/bin/rollup"
+      },
+      "engines": {
+        "node": ">=18.0.0",
+        "npm": ">=8.0.0"
+      },
+      "optionalDependencies": {
+        "@rollup/rollup-android-arm-eabi": "4.62.2",
+        "@rollup/rollup-android-arm64": "4.62.2",
+        "@rollup/rollup-darwin-arm64": "4.62.2",
+        "@rollup/rollup-darwin-x64": "4.62.2",
+        "@rollup/rollup-freebsd-arm64": "4.62.2",
+        "@rollup/rollup-freebsd-x64": "4.62.2",
+        "@rollup/rollup-linux-arm-gnueabihf": "4.62.2",
+        "@rollup/rollup-linux-arm-musleabihf": "4.62.2",
+        "@rollup/rollup-linux-arm64-gnu": "4.62.2",
+        "@rollup/rollup-linux-arm64-musl": "4.62.2",
+        "@rollup/rollup-linux-loong64-gnu": "4.62.2",
+        "@rollup/rollup-linux-loong64-musl": "4.62.2",
+        "@rollup/rollup-linux-ppc64-gnu": "4.62.2",
+        "@rollup/rollup-linux-ppc64-musl": "4.62.2",
+        "@rollup/rollup-linux-riscv64-gnu": "4.62.2",
+        "@rollup/rollup-linux-riscv64-musl": "4.62.2",
+        "@rollup/rollup-linux-s390x-gnu": "4.62.2",
+        "@rollup/rollup-linux-x64-gnu": "4.62.2",
+        "@rollup/rollup-linux-x64-musl": "4.62.2",
+        "@rollup/rollup-openbsd-x64": "4.62.2",
+        "@rollup/rollup-openharmony-arm64": "4.62.2",
+        "@rollup/rollup-win32-arm64-msvc": "4.62.2",
+        "@rollup/rollup-win32-ia32-msvc": "4.62.2",
+        "@rollup/rollup-win32-x64-gnu": "4.62.2",
+        "@rollup/rollup-win32-x64-msvc": "4.62.2",
+        "fsevents": "~2.3.2"
+      }
+    },
+    "node_modules/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/set-cookie-parser": {
+      "version": "2.7.2",
+      "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
+      "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
+      "license": "MIT"
+    },
+    "node_modules/siginfo": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+      "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+      "dev": true,
+      "license": "ISC"
     },
     "node_modules/source-map-js": {
       "version": "1.2.1",
@@ -1681,6 +4857,82 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/space-separated-tokens": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz",
+      "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/wooorm"
+      }
+    },
+    "node_modules/stackback": {
+      "version": "0.0.2",
+      "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+      "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/std-env": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",
+      "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/stringify-entities": {
+      "version": "4.0.4",
+      "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
+      "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==",
+      "license": "MIT",
+      "dependencies": {
+        "character-entities-html4": "^2.0.0",
+        "character-entities-legacy": "^3.0.0"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/wooorm"
+      }
+    },
+    "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/style-to-js": {
+      "version": "1.1.21",
+      "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz",
+      "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==",
+      "license": "MIT",
+      "dependencies": {
+        "style-to-object": "1.0.14"
+      }
+    },
+    "node_modules/style-to-object": {
+      "version": "1.0.14",
+      "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz",
+      "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==",
+      "license": "MIT",
+      "dependencies": {
+        "inline-style-parser": "0.2.7"
+      }
+    },
+    "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.3.3",
       "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
@@ -1700,6 +4952,70 @@
         "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.2.4",
+      "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz",
+      "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/tinyglobby": {
+      "version": "0.2.17",
+      "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+      "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "fdir": "^6.5.0",
+        "picomatch": "^4.0.4"
+      },
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/SuperchupuDev"
+      }
+    },
+    "node_modules/tinyrainbow": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
+      "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=14.0.0"
+      }
+    },
+    "node_modules/tldts": {
+      "version": "7.4.9",
+      "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.9.tgz",
+      "integrity": "sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "tldts-core": "^7.4.9"
+      },
+      "bin": {
+        "tldts": "bin/cli.js"
+      }
+    },
+    "node_modules/tldts-core": {
+      "version": "7.4.9",
+      "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.9.tgz",
+      "integrity": "sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/to-regex-range": {
       "version": "5.0.1",
       "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -1711,6 +5027,986 @@
       "engines": {
         "node": ">=8.0"
       }
+    },
+    "node_modules/tough-cookie": {
+      "version": "6.0.2",
+      "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",
+      "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==",
+      "dev": true,
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "tldts": "^7.0.5"
+      },
+      "engines": {
+        "node": ">=16"
+      }
+    },
+    "node_modules/tr46": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
+      "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "punycode": "^2.3.1"
+      },
+      "engines": {
+        "node": ">=20"
+      }
+    },
+    "node_modules/trim-lines": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
+      "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/wooorm"
+      }
+    },
+    "node_modules/trough": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz",
+      "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/wooorm"
+      }
+    },
+    "node_modules/typescript": {
+      "version": "5.9.3",
+      "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+      "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+      "devOptional": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "tsc": "bin/tsc",
+        "tsserver": "bin/tsserver"
+      },
+      "engines": {
+        "node": ">=14.17"
+      }
+    },
+    "node_modules/undici": {
+      "version": "7.28.0",
+      "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
+      "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=20.18.1"
+      }
+    },
+    "node_modules/undici-types": {
+      "version": "7.18.2",
+      "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
+      "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/unified": {
+      "version": "11.0.5",
+      "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
+      "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/unist": "^3.0.0",
+        "bail": "^2.0.0",
+        "devlop": "^1.0.0",
+        "extend": "^3.0.0",
+        "is-plain-obj": "^4.0.0",
+        "trough": "^2.0.0",
+        "vfile": "^6.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/unist-util-is": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz",
+      "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/unist": "^3.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/unist-util-position": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz",
+      "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/unist": "^3.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/unist-util-stringify-position": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz",
+      "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/unist": "^3.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/unist-util-visit": {
+      "version": "5.1.0",
+      "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz",
+      "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/unist": "^3.0.0",
+        "unist-util-is": "^6.0.0",
+        "unist-util-visit-parents": "^6.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/unist-util-visit-parents": {
+      "version": "6.0.2",
+      "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz",
+      "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/unist": "^3.0.0",
+        "unist-util-is": "^6.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/update-browserslist-db": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+      "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/browserslist"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/browserslist"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "escalade": "^3.2.0",
+        "picocolors": "^1.1.1"
+      },
+      "bin": {
+        "update-browserslist-db": "cli.js"
+      },
+      "peerDependencies": {
+        "browserslist": ">= 4.21.0"
+      }
+    },
+    "node_modules/vfile": {
+      "version": "6.0.3",
+      "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
+      "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/unist": "^3.0.0",
+        "vfile-message": "^4.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/vfile-message": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz",
+      "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/unist": "^3.0.0",
+        "unist-util-stringify-position": "^4.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/unified"
+      }
+    },
+    "node_modules/vite": {
+      "version": "6.4.3",
+      "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
+      "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "esbuild": "^0.25.0",
+        "fdir": "^6.4.4",
+        "picomatch": "^4.0.2",
+        "postcss": "^8.5.3",
+        "rollup": "^4.34.9",
+        "tinyglobby": "^0.2.13"
+      },
+      "bin": {
+        "vite": "bin/vite.js"
+      },
+      "engines": {
+        "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/vitejs/vite?sponsor=1"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.3"
+      },
+      "peerDependencies": {
+        "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+        "jiti": ">=1.21.0",
+        "less": "*",
+        "lightningcss": "^1.21.0",
+        "sass": "*",
+        "sass-embedded": "*",
+        "stylus": "*",
+        "sugarss": "*",
+        "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/vite/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/vite/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/vite/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/vite/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/vite/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/vite/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/vite/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/vite/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/vite/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/vite/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/vite/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/vite/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/vite/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/vite/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/vite/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/vite/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/vite/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/vite/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/vite/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/vite/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/vite/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/vite/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/vite/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/vite/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/vite/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/vite/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/vite/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/vitest": {
+      "version": "4.1.10",
+      "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz",
+      "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@vitest/expect": "4.1.10",
+        "@vitest/mocker": "4.1.10",
+        "@vitest/pretty-format": "4.1.10",
+        "@vitest/runner": "4.1.10",
+        "@vitest/snapshot": "4.1.10",
+        "@vitest/spy": "4.1.10",
+        "@vitest/utils": "4.1.10",
+        "es-module-lexer": "^2.0.0",
+        "expect-type": "^1.3.0",
+        "magic-string": "^0.30.21",
+        "obug": "^2.1.1",
+        "pathe": "^2.0.3",
+        "picomatch": "^4.0.3",
+        "std-env": "^4.0.0-rc.1",
+        "tinybench": "^2.9.0",
+        "tinyexec": "^1.0.2",
+        "tinyglobby": "^0.2.15",
+        "tinyrainbow": "^3.1.0",
+        "vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
+        "why-is-node-running": "^2.3.0"
+      },
+      "bin": {
+        "vitest": "vitest.mjs"
+      },
+      "engines": {
+        "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      },
+      "peerDependencies": {
+        "@edge-runtime/vm": "*",
+        "@opentelemetry/api": "^1.9.0",
+        "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
+        "@vitest/browser-playwright": "4.1.10",
+        "@vitest/browser-preview": "4.1.10",
+        "@vitest/browser-webdriverio": "4.1.10",
+        "@vitest/coverage-istanbul": "4.1.10",
+        "@vitest/coverage-v8": "4.1.10",
+        "@vitest/ui": "4.1.10",
+        "happy-dom": "*",
+        "jsdom": "*",
+        "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+      },
+      "peerDependenciesMeta": {
+        "@edge-runtime/vm": {
+          "optional": true
+        },
+        "@opentelemetry/api": {
+          "optional": true
+        },
+        "@types/node": {
+          "optional": true
+        },
+        "@vitest/browser-playwright": {
+          "optional": true
+        },
+        "@vitest/browser-preview": {
+          "optional": true
+        },
+        "@vitest/browser-webdriverio": {
+          "optional": true
+        },
+        "@vitest/coverage-istanbul": {
+          "optional": true
+        },
+        "@vitest/coverage-v8": {
+          "optional": true
+        },
+        "@vitest/ui": {
+          "optional": true
+        },
+        "happy-dom": {
+          "optional": true
+        },
+        "jsdom": {
+          "optional": true
+        },
+        "vite": {
+          "optional": false
+        }
+      }
+    },
+    "node_modules/void-elements": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
+      "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "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": "8.0.1",
+      "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
+      "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
+      "dev": true,
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=20"
+      }
+    },
+    "node_modules/whatwg-mimetype": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
+      "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=20"
+      }
+    },
+    "node_modules/whatwg-url": {
+      "version": "16.0.1",
+      "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
+      "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@exodus/bytes": "^1.11.0",
+        "tr46": "^6.0.0",
+        "webidl-conversions": "^8.0.1"
+      },
+      "engines": {
+        "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+      }
+    },
+    "node_modules/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/xml-name-validator": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
+      "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/xmlchars": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
+      "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/yallist": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+      "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/zwitch": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
+      "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/wooorm"
+      }
     }
   }
 }
diff --git a/package.json b/package.json
index 6c306e0..02dcb30 100644
--- a/package.json
+++ b/package.json
@@ -1,21 +1,57 @@
 {
   "private": true,
   "type": "module",
+  "engines": {
+    "node": ">=20"
+  },
   "scripts": {
-    "build": "node build.js"
+    "build": "node build.js",
+    "dev": "vite --config vite.config.ts",
+    "test:frontend": "vitest run",
+    "typecheck": "tsc --noEmit",
+    "test:e2e": "playwright test --config=e2e/playwright.config.ts",
+    "typecheck:e2e": "tsc -p e2e/tsconfig.json --noEmit"
   },
   "devDependencies": {
-    "esbuild": "^0.28.0"
+    "@playwright/test": "^1.61.1",
+    "@testing-library/dom": "^10.4.1",
+    "@testing-library/jest-dom": "^7.0.0",
+    "@testing-library/react": "^16.3.2",
+    "@types/leaflet": "^1.9.17",
+    "@types/node": "^24.0.0",
+    "@types/react": "^19",
+    "@types/react-dom": "^19",
+    "@vitejs/plugin-react": "^4",
+    "jsdom": "^29.1.1",
+    "typescript": "^5.8",
+    "vite": "^6",
+    "vitest": "^4.1.10"
   },
   "dependencies": {
     "@fontsource-variable/ibm-plex-sans": "^5",
     "@fontsource/ibm-plex-mono": "^5",
     "@tailwindcss/cli": "^4",
+    "@tanstack/react-query": "^5.101.4",
     "chart.js": "^4",
     "daisyui": "^5",
+    "i18next": "^25",
+    "i18next-browser-languagedetector": "^8",
     "leaflet": "^1.9.4",
-    "lit-html": "^3",
-    "qrcodejs": "^1.0.0",
+    "react": "^19",
+    "react-chartjs-2": "^5",
+    "react-dom": "^19",
+    "react-i18next": "^15",
+    "react-leaflet": "^5",
+    "react-markdown": "^10.1.0",
+    "react-qr-code": "^2.2.0",
+    "react-router": "^7",
+    "rehype-autolink-headings": "^7.1.0",
+    "rehype-slug": "^6.0.0",
+    "remark-gfm": "^4.0.1",
     "tailwindcss": "^4"
+  },
+  "allowScripts": {
+    "esbuild@0.25.12": true,
+    "@parcel/watcher@2.5.1": true
   }
 }
diff --git a/pyproject.toml b/pyproject.toml
index 9433f84..dbb6877 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -42,7 +42,6 @@ dependencies = [
     "aiosqlite>=0.19.0",
     "pyyaml>=6.0.0",
     "python-frontmatter>=1.0.0",
-    "markdown>=3.5.0",
     "prometheus-client>=0.20.0",
     "meshcoredecoder>=0.3.2",
     "redis[hiredis]>=5.0.0",
@@ -124,7 +123,6 @@ module = [
     "uvicorn.*",
     "alembic.*",
     "frontmatter.*",
-    "markdown.*",
     "prometheus_client.*",
     "meshcoredecoder.*",
     "authlib.*",
@@ -151,6 +149,12 @@ module = [
 ]
 ignore_errors = true
 
+# e2e helper script (run with the project venv); itsdangerous stubs are not
+# present in the isolated pre-commit mypy environment.
+[[tool.mypy.overrides]]
+module = ["mint_session"]
+ignore_missing_imports = true
+
 [tool.pytest.ini_options]
 minversion = "7.0"
 asyncio_mode = "auto"
@@ -163,9 +167,6 @@ addopts = [
     "-q",
     "--strict-markers",
 ]
-markers = [
-    "e2e: end-to-end tests requiring Docker services (skipped unless --e2e)",
-]
 filterwarnings = [
     "ignore::DeprecationWarning",
 ]
diff --git a/src/meshcore_hub/api/routes/channels.py b/src/meshcore_hub/api/routes/channels.py
index a9d40d6..3d3e794 100644
--- a/src/meshcore_hub/api/routes/channels.py
+++ b/src/meshcore_hub/api/routes/channels.py
@@ -25,7 +25,7 @@ router = APIRouter()
 
 def _channels_key_builder(request: Request) -> str:
     role = resolve_user_role(request) or "anonymous"
-    return f"channels:role={role}:{sorted_query_string(request)}"
+    return f"{request.url.path}:role={role}:{sorted_query_string(request)}"
 
 
 def _channel_to_read(channel: Channel, include_key: bool = False) -> ChannelRead:
diff --git a/src/meshcore_hub/api/routes/messages.py b/src/meshcore_hub/api/routes/messages.py
index cd1ad76..86f06e0 100644
--- a/src/meshcore_hub/api/routes/messages.py
+++ b/src/meshcore_hub/api/routes/messages.py
@@ -30,7 +30,7 @@ VALID_MSG_SORT_COLUMNS = {"time", "type", "from", "message"}
 
 def _messages_key_builder(request: Request) -> str:
     role = resolve_user_role(request) or "anonymous"
-    return f"messages:role={role}:{sorted_query_string(request)}"
+    return f"{request.url.path}:role={role}:{sorted_query_string(request)}"
 
 
 def _get_tag_name(node: Optional[Node]) -> Optional[str]:
diff --git a/src/meshcore_hub/collector/routes.py b/src/meshcore_hub/collector/routes.py
index ddd6792..6726125 100644
--- a/src/meshcore_hub/collector/routes.py
+++ b/src/meshcore_hub/collector/routes.py
@@ -786,8 +786,8 @@ def evaluate_route_history(
 
 
 # Thresholds for ``compute_average_quality`` — kept in sync with the
-# ``averageTier`` helper in ``web/static/js/charts.js`` so the server-side
-# rolling-average badge matches the chart's per-route line color.
+# ``averageRouteTier`` helper in ``web/static/js/spa-react/utils/charts.ts`` so
+# the server-side rolling-average badge matches the chart's per-route line color.
 AVERAGE_QUALITY_CLEAR_AT = 1.5
 AVERAGE_QUALITY_MARGINAL_AT = 0.75
 
diff --git a/src/meshcore_hub/web/app.py b/src/meshcore_hub/web/app.py
index ae9aa01..1ec54d0 100644
--- a/src/meshcore_hub/web/app.py
+++ b/src/meshcore_hub/web/app.py
@@ -65,7 +65,9 @@ def _sanitize_header_value(value: str) -> str:
 
 
 def _load_asset_manifest() -> dict[str, Any]:
-    """Load the esbuild asset manifest from dist/assets.json.
+    """Load the asset manifest from dist/assets.json.
+
+    Supports both Vite-generated and legacy esbuild manifest formats.
 
     Returns:
         Manifest dict with entry names, vendor hashes, and locale version.
@@ -359,6 +361,8 @@ def _build_config_json(app: FastAPI, request: Request) -> str:
         "locale_version": getattr(app.state, "locale_version", ""),
         "system_maintenance": app.state.system_maintenance,
         "spam_score_threshold": app.state.spam_score_threshold,
+        "system_announcement": app.state.system_announcement,
+        "network_announcement": app.state.network_announcement,
     }
 
     role_names = {
@@ -571,24 +575,18 @@ def create_app(
         if network_announcement is not None
         else settings.network_announcement
     )
-    if raw_announcement:
-        import markdown
-
-        app.state.network_announcement = markdown.markdown(raw_announcement)
-    else:
-        app.state.network_announcement = None
+    app.state.network_announcement = (
+        raw_announcement.strip() if raw_announcement else None
+    )
 
     raw_system_announcement = (
         system_announcement
         if system_announcement is not None
         else settings.system_announcement
     )
-    if raw_system_announcement:
-        import markdown
-
-        app.state.system_announcement = markdown.markdown(raw_system_announcement)
-    else:
-        app.state.system_announcement = None
+    app.state.system_announcement = (
+        raw_system_announcement.strip() if raw_system_announcement else None
+    )
 
     app.state.system_maintenance = (
         system_maintenance
@@ -706,10 +704,11 @@ def create_app(
     page_loader.load_pages()
     app.state.page_loader = page_loader
 
-    # Load esbuild asset manifest for cache-busted filenames
+    # Load asset manifest for cache-busted filenames (Vite or legacy esbuild)
     manifest = _load_asset_manifest()
     app.state.asset_manifest = manifest
     app.state.asset_app_js = manifest.get("app.js", "")
+    app.state.asset_app_css = manifest.get("app.css", "")
     app.state.vendor_hashes = manifest.get("vendor", {})
     app.state.locale_version = manifest.get("locale_version", "")
 
@@ -996,7 +995,7 @@ def create_app(
             {
                 "slug": page.slug,
                 "title": page.title,
-                "content_html": page.content_html,
+                "content_markdown": page.content_markdown,
             }
         )
 
@@ -1233,14 +1232,14 @@ def create_app(
     # --- SPA Catch-All (MUST be last) ---
     @app.api_route("/{path:path}", methods=["GET"], tags=["SPA"], response_model=None)
     async def spa_catchall(request: Request, path: str = "") -> Response:
-        """Serve the SPA shell for all non-API routes."""
-        templates_inst: Jinja2Templates = request.app.state.templates
-        features = request.app.state.features
-        page_loader = request.app.state.page_loader
-        custom_pages = (
-            page_loader.get_menu_pages() if features.get("pages", True) else []
-        )
+        """Serve the SPA shell for all non-API routes.
 
+        The shell is pure bootstrap: SEO , theme-init, the embedded
+        ``__APP_CONFIG__`` JSON, and the Vite bundle mount point. All visual
+        UI (navbar, banners, pages, footer) is rendered client-side by React
+        from ``__APP_CONFIG__``.
+        """
+        templates_inst: Jinja2Templates = request.app.state.templates
         config_json = _build_config_json(request.app, request)
 
         return templates_inst.TemplateResponse(
@@ -1248,26 +1247,14 @@ def create_app(
             "spa.html",
             {
                 "network_name": request.app.state.network_name,
-                "network_city": request.app.state.network_city,
-                "network_country": request.app.state.network_country,
-                "network_contact_email": request.app.state.network_contact_email,
-                "network_contact_discord": request.app.state.network_contact_discord,
-                "network_contact_github": request.app.state.network_contact_github,
-                "network_contact_youtube": request.app.state.network_contact_youtube,
                 "network_welcome_text": request.app.state.network_welcome_text,
-                "network_announcement": request.app.state.network_announcement,
-                "system_announcement": request.app.state.system_announcement,
-                "system_maintenance": request.app.state.system_maintenance,
-                "oidc_enabled": request.app.state.oidc_enabled,
-                "features": features,
-                "custom_pages": custom_pages,
                 "logo_url": request.app.state.logo_url,
                 "logo_invert_light": request.app.state.logo_invert_light,
                 "version": __version__,
                 "default_theme": request.app.state.web_theme,
                 "config_json": config_json,
                 "asset_app_js": request.app.state.asset_app_js,
-                "vendor_hashes": request.app.state.vendor_hashes,
+                "asset_app_css": request.app.state.asset_app_css,
             },
         )
 
diff --git a/src/meshcore_hub/web/pages.py b/src/meshcore_hub/web/pages.py
index 3c60265..348de84 100644
--- a/src/meshcore_hub/web/pages.py
+++ b/src/meshcore_hub/web/pages.py
@@ -1,4 +1,10 @@
-"""Custom markdown pages loader for MeshCore Hub Web Dashboard."""
+"""Custom markdown pages loader for MeshCore Hub Web Dashboard.
+
+Pages are stored as raw markdown and rendered client-side by the React
+```` component (react-markdown + remark-gfm). The loader only
+parses the YAML frontmatter (slug/title/menu_order); the body is shipped
+verbatim so the SPA is the single source of truth for rendering.
+"""
 
 import logging
 from dataclasses import dataclass
@@ -6,7 +12,6 @@ from pathlib import Path
 from typing import Optional
 
 import frontmatter
-import markdown
 
 logger = logging.getLogger(__name__)
 
@@ -18,7 +23,7 @@ class CustomPage:
     slug: str
     title: str
     menu_order: int
-    content_html: str
+    content_markdown: str
     file_path: str
 
     @property
@@ -38,10 +43,6 @@ class PageLoader:
         """
         self.pages_dir = Path(pages_dir)
         self._pages: dict[str, CustomPage] = {}
-        self._md = markdown.Markdown(
-            extensions=["tables", "fenced_code", "toc"],
-            output_format="html",
-        )
 
     def load_pages(self) -> None:
         """Load all markdown pages from the pages directory."""
@@ -67,7 +68,7 @@ class PageLoader:
         logger.info(f"Loaded {len(self._pages)} custom page(s)")
 
     def _load_page(self, file_path: Path) -> Optional[CustomPage]:
-        """Load a single markdown page.
+        """Load a single markdown page (frontmatter + raw body).
 
         Args:
             file_path: Path to the markdown file.
@@ -83,15 +84,11 @@ class PageLoader:
         title = post.get("title", slug.replace("-", " ").replace("_", " ").title())
         menu_order = post.get("menu_order", 100)
 
-        # Convert markdown to HTML
-        self._md.reset()
-        content_html = self._md.convert(post.content)
-
         return CustomPage(
             slug=slug,
             title=title,
             menu_order=menu_order,
-            content_html=content_html,
+            content_markdown=post.content,
             file_path=str(file_path),
         )
 
diff --git a/src/meshcore_hub/web/static/css/app.css b/src/meshcore_hub/web/static/css/app.css
index afc28dd..a331740 100644
--- a/src/meshcore_hub/web/static/css/app.css
+++ b/src/meshcore_hub/web/static/css/app.css
@@ -193,6 +193,7 @@
     font-weight: 700;
     margin-top: 1.5rem;
     margin-bottom: 1rem;
+    scroll-margin-top: 6rem;
 }
 
 .prose h2 {
@@ -200,6 +201,7 @@
     font-weight: 600;
     margin-top: 1.25rem;
     margin-bottom: 0.75rem;
+    scroll-margin-top: 6rem;
 }
 
 .prose h3 {
@@ -207,6 +209,7 @@
     font-weight: 600;
     margin-top: 1rem;
     margin-bottom: 0.5rem;
+    scroll-margin-top: 6rem;
 }
 
 .prose h4 {
@@ -214,6 +217,7 @@
     font-weight: 600;
     margin-top: 1rem;
     margin-bottom: 0.5rem;
+    scroll-margin-top: 6rem;
 }
 
 .prose p {
@@ -255,6 +259,16 @@
     color: color-mix(in oklab, var(--color-primary) 70%, var(--color-base-content));
 }
 
+/* Heading anchor wrappers (rehype-autolink-headings, behavior: "wrap"):
+   inherit the heading color instead of the link color, and only underline on hover. */
+.prose :is(h1, h2, h3, h4, h5, h6) > a {
+    color: inherit;
+    text-decoration: none;
+}
+.prose :is(h1, h2, h3, h4, h5, h6) > a:hover {
+    text-decoration: underline;
+}
+
 .prose code {
     background: var(--color-base-200);
     padding: 0.125rem 0.25rem;
diff --git a/src/meshcore_hub/web/static/js/charts.js b/src/meshcore_hub/web/static/js/charts.js
deleted file mode 100644
index 2dba18c..0000000
--- a/src/meshcore_hub/web/static/js/charts.js
+++ /dev/null
@@ -1,617 +0,0 @@
-/**
- * MeshCore Hub - Chart.js Helpers
- *
- * Provides common chart configuration and initialization helpers
- * for activity charts used on home and dashboard pages.
- */
-
-// Match app typography (IBM Plex Sans); Chart.js defaults to Helvetica/Arial.
-if (typeof Chart !== 'undefined') {
-    Chart.defaults.font.family = '"IBM Plex Sans", ui-sans-serif, system-ui, sans-serif';
-}
-
-/**
- * Format a number with locale-appropriate grouping separators.
- * Uses the visitor's browser locale (no explicit locale argument).
- * @param {number} v
- * @returns {string}
- */
-function formatNumber(v) {
-    return new Intl.NumberFormat().format(v);
-}
-
-/**
- * Read page colors from CSS custom properties (defined in app.css :root).
- * Falls back to hardcoded values if CSS vars are unavailable.
- */
-function getCSSColor(varName, fallback) {
-    return getComputedStyle(document.documentElement).getPropertyValue(varName).trim() || fallback;
-}
-
-function withAlpha(color, alpha) {
-    // oklch(0.65 0.24 265) -> oklch(0.65 0.24 265 / 0.1)
-    return color.replace(')', ' / ' + alpha + ')');
-}
-
-const ChartColors = {
-    get nodes()        { return getCSSColor('--color-nodes', 'oklch(0.65 0.24 265)'); },
-    get nodesFill()    { return withAlpha(this.nodes, 0.1); },
-    get adverts()      { return getCSSColor('--color-adverts', 'oklch(0.7 0.17 330)'); },
-    get advertsFill()  { return withAlpha(this.adverts, 0.1); },
-    get messages()     { return getCSSColor('--color-messages', 'oklch(0.75 0.18 180)'); },
-    get messagesFill() { return withAlpha(this.messages, 0.1); },
-    get packets()      { return getCSSColor('--color-packets', 'oklch(0.72 0.17 145)'); },
-    get packetsFill()  { return withAlpha(this.packets, 0.1); },
-    get routes()       { return getCSSColor('--color-routes', 'oklch(0.72 0.17 30)'); },
-    get routesFill()   { return withAlpha(this.routes, 0.1); },
-
-    // Neutral grays (not page-specific)
-    grid: 'oklch(0.4 0 0 / 0.2)',
-    text: 'oklch(0.7 0 0)',
-    tooltipBg: 'oklch(0.25 0 0)',
-    tooltipText: 'oklch(0.9 0 0)',
-    tooltipBorder: 'oklch(0.4 0 0)',
-
-    // Qualitative palette for stacked breakdown bars (6 hues + neutral grey
-    // for "other"). Hardcoded oklch values render consistently across light
-    // and dark themes without extra CSS tokens.
-    breakdown: [
-        'oklch(0.65 0.24 265)',   // blue
-        'oklch(0.7 0.17 330)',    // magenta
-        'oklch(0.75 0.18 180)',   // teal
-        'oklch(0.72 0.17 145)',   // green
-        'oklch(0.7 0.19 80)',     // yellow-green
-        'oklch(0.65 0.22 25)',    // orange
-        'oklch(0.55 0 0)'        // neutral grey (for "other")
-    ],
-
-    // Semantic quality palette for route health charts. Hardcoded oklch
-    // values (same approach as `breakdown`) — app.css defines no semantic
-    // status colors.
-    quality: {
-        clear:       'oklch(0.72 0.17 145)',
-        marginal:    'oklch(0.75 0.18 85)',
-        failing:     'oklch(0.62 0.24 25)',
-        no_coverage: 'oklch(0.65 0.15 250)',
-        disabled:    'oklch(0.55 0 0)'
-    }
-};
-
-/**
- * Create common chart options with optional legend
- * @param {boolean} showLegend - Whether to show the legend
- * @returns {Object} Chart.js options object
- */
-function createChartOptions(showLegend) {
-    return {
-        responsive: true,
-        maintainAspectRatio: false,
-        plugins: {
-            legend: {
-                display: showLegend,
-                position: 'top',
-                align: 'end',
-                labels: {
-                    color: ChartColors.text,
-                    boxWidth: 12,
-                    padding: 8
-                }
-            },
-            tooltip: {
-                mode: 'index',
-                intersect: false,
-                backgroundColor: ChartColors.tooltipBg,
-                titleColor: ChartColors.tooltipText,
-                bodyColor: ChartColors.tooltipText,
-                borderColor: ChartColors.tooltipBorder,
-                borderWidth: 1,
-                callbacks: {
-                    label: function(ctx) {
-                        const label = ctx.dataset.label || '';
-                        const value = formatNumber(ctx.parsed.y);
-                        return label ? label + ': ' + value : value;
-                    }
-                }
-            }
-        },
-        scales: {
-            x: {
-                grid: { color: ChartColors.grid },
-                ticks: {
-                    color: ChartColors.text,
-                    maxRotation: 45,
-                    minRotation: 45,
-                    maxTicksLimit: 10
-                }
-            },
-            y: {
-                beginAtZero: true,
-                grid: { color: ChartColors.grid },
-                ticks: {
-                    color: ChartColors.text,
-                    precision: 0,
-                    callback: function(value) { return formatNumber(value); }
-                }
-            }
-        },
-        interaction: {
-            mode: 'nearest',
-            axis: 'x',
-            intersect: false
-        }
-    };
-}
-
-/**
- * Format date labels for chart display (e.g., "8 Feb")
- * @param {Array} data - Array of objects with 'date' property
- * @returns {Array} Formatted date strings
- */
-function formatDateLabels(data) {
-    return data.map(function(d) {
-        var date = new Date(d.date);
-        return date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' });
-    });
-}
-
-/**
- * Create a single-dataset line chart
- * @param {string} canvasId - ID of the canvas element
- * @param {Object} data - Data object with 'data' array containing {date, count} objects
- * @param {string} label - Dataset label
- * @param {string} borderColor - Line color
- * @param {string} backgroundColor - Fill color
- * @param {boolean} fill - Whether to fill under the line
- */
-function createLineChart(canvasId, data, label, borderColor, backgroundColor, fill) {
-    var ctx = document.getElementById(canvasId);
-    if (!ctx || !data || !data.data || data.data.length === 0) {
-        return null;
-    }
-
-    return new Chart(ctx, {
-        type: 'line',
-        data: {
-            labels: formatDateLabels(data.data),
-            datasets: [{
-                label: label,
-                data: data.data.map(function(d) { return d.count; }),
-                borderColor: borderColor,
-                backgroundColor: backgroundColor,
-                fill: fill,
-                tension: 0.3,
-                pointRadius: 2,
-                pointHoverRadius: 5
-            }]
-        },
-        options: createChartOptions(false)
-    });
-}
-
-/**
- * Create a multi-dataset activity chart (for home page).
- * Pass null for advertData or messageData to omit that series.
- * @param {string} canvasId - ID of the canvas element
- * @param {Object|null} advertData - Advertisement data with 'data' array, or null to omit
- * @param {Object|null} messageData - Message data with 'data' array, or null to omit
- */
-function createActivityChart(canvasId, advertData, messageData) {
-    var ctx = document.getElementById(canvasId);
-    if (!ctx) return null;
-
-    // Build datasets from whichever series are provided
-    var datasets = [];
-    var labels = null;
-
-    if (advertData && advertData.data && advertData.data.length > 0) {
-        if (!labels) labels = formatDateLabels(advertData.data);
-        datasets.push({
-            label: (window.t && window.t('entities.advertisements')) || 'Advertisements',
-            data: advertData.data.map(function(d) { return d.count; }),
-            borderColor: ChartColors.adverts,
-            backgroundColor: ChartColors.advertsFill,
-            fill: true,
-            tension: 0.3,
-            pointRadius: 2,
-            pointHoverRadius: 5
-        });
-    }
-
-    if (messageData && messageData.data && messageData.data.length > 0) {
-        if (!labels) labels = formatDateLabels(messageData.data);
-        datasets.push({
-            label: (window.t && window.t('entities.messages')) || 'Messages',
-            data: messageData.data.map(function(d) { return d.count; }),
-            borderColor: ChartColors.messages,
-            backgroundColor: ChartColors.messagesFill,
-            fill: true,
-            tension: 0.3,
-            pointRadius: 2,
-            pointHoverRadius: 5
-        });
-    }
-
-    if (datasets.length === 0 || !labels) return null;
-
-    return new Chart(ctx, {
-        type: 'line',
-        data: { labels: labels, datasets: datasets },
-        options: createChartOptions(true)
-    });
-}
-
-/**
- * Create a horizontal 100% stacked bar chart from labeled buckets.
- *
- * Each bucket becomes one dataset sized proportionally to its count. The
- * x-axis is fixed at 0-100% and tooltips show the raw count and percentage.
- * Returns null when buckets is empty or the total is zero (matching
- * createLineChart's empty-data idiom).
- *
- * @param {string} canvasId - ID of the canvas element
- * @param {Array|null} buckets - Array of {label, count} objects
- * @param {Array} colors - Ordered color strings (one per bucket)
- * @returns {Chart|null}
- */
-function createStackedBarChart(canvasId, buckets, colors) {
-    var ctx = document.getElementById(canvasId);
-    if (!ctx || !buckets || buckets.length === 0) return null;
-
-    var total = buckets.reduce(function(sum, b) { return sum + b.count; }, 0);
-    if (total === 0) return null;
-
-    var datasets = buckets.map(function(bucket, i) {
-        var pct = (bucket.count / total) * 100;
-        return {
-            label: bucket.label,
-            data: [pct],
-            backgroundColor: colors[i % colors.length],
-            borderColor: colors[i % colors.length],
-            borderWidth: 1,
-            rawCount: bucket.count
-        };
-    });
-
-    return new Chart(ctx, {
-        type: 'bar',
-        data: {
-            labels: [''],
-            datasets: datasets
-        },
-        options: {
-            responsive: true,
-            maintainAspectRatio: false,
-            indexAxis: 'y',
-            plugins: {
-                legend: { display: false },
-                tooltip: {
-                    backgroundColor: ChartColors.tooltipBg,
-                    titleColor: ChartColors.tooltipText,
-                    bodyColor: ChartColors.tooltipText,
-                    borderColor: ChartColors.tooltipBorder,
-                    borderWidth: 1,
-                    callbacks: {
-                        label: function(ctx) {
-                            var label = ctx.dataset.label || '';
-                            var count = formatNumber(ctx.dataset.rawCount);
-                            var pct = ctx.parsed.x.toFixed(1);
-                            return label + ': ' + count + ' (' + pct + '%)';
-                        }
-                    }
-                }
-            },
-            scales: {
-                x: {
-                    max: 100,
-                    stacked: true,
-                    grid: { color: ChartColors.grid },
-                    ticks: {
-                        color: ChartColors.text,
-                        callback: function(value) { return value + '%'; }
-                    }
-                },
-                y: {
-                    stacked: true,
-                    grid: { display: false },
-                    ticks: { display: false }
-                }
-            },
-            interaction: {
-                mode: 'nearest',
-                intersect: false
-            }
-        }
-    });
-}
-
-/**
- * Map a route-quality enum value to the merged 3-tier space used by the
- * dashboard trend chart and Route Health widget.
- *
- *   ``clear``       → clear
- *   ``marginal``    → marginal
- *   anything else   → failing   (covers ``failing``, ``unknown``,
- *                                 ``no_coverage``, ``disabled``, null)
- */
-function routeQualityToTier(q) {
-    if (q === 'clear') return 'clear';
-    if (q === 'marginal') return 'marginal';
-    return 'failing';
-}
-
-/**
- * Mean tier over the displayed window. Maps the 3-tier space onto a
- * 0/1/2 numeric scale (failing < marginal < clear), averages, then
- * buckets back: >=1.5 → clear, >=0.75 → marginal, else failing.
- * Empty history falls through to failing (matches routeQualityToTier's
- * default for unknown / null quality).
- *
- * Kept in sync with ``compute_average_quality`` in
- * ``src/meshcore_hub/collector/routes.py`` so the server-side rolling
- * badge matches the client-side chart line color.
- *
- * @param {Array<{quality: string}>|null} history
- * @returns {string} tier name (``clear`` / ``marginal`` / ``failing``)
- */
-function averageRouteTier(history) {
-    if (!history || history.length === 0) return 'failing';
-    var sum = 0;
-    for (var i = 0; i < history.length; i++) {
-        var tier = routeQualityToTier(history[i].quality);
-        sum += (tier === 'clear' ? 2 : tier === 'marginal' ? 1 : 0);
-    }
-    var mean = sum / history.length;
-    if (mean >= 1.5) return 'clear';
-    if (mean >= 0.75) return 'marginal';
-    return 'failing';
-}
-
-/**
- * Create a multi-line route-status trend chart for the dashboard.
- *
- * Each route becomes one line plotted on a 3-tier categorical Y axis
- * (``failing`` → ``marginal`` → ``clear``, bottom to top). The line's
- * color reflects the route's CURRENT quality (its latest evaluation),
- * so multiple routes in the same health band share a color — the chart
- * reads as a fleet-health overview rather than per-route identity.
- * Hover tooltips still show the route label, tier, and matched_count.
- *
- * Input is the ``routes`` array from ``GET /dashboard/routes-overview``.
- * The top ``maxRoutes`` routes by current ``matched_count`` are drawn;
- * the rest are dropped silently (summing quality tiers is meaningless).
- *
- * Quality → tier mapping (per the merged-3-tier design):
- *   ``clear``       → clear
- *   ``marginal``    → marginal
- *   anything else   → failing   (covers ``failing``, ``unknown``,
- *                                 ``no_coverage``, ``disabled``, null)
- *
- * @param {string} canvasId - ID of the canvas element
- * @param {Array|null} routes - Array of RouteOverviewEntry objects
- * @param {number} [maxRoutes=6] - Top-N routes drawn distinctly
- * @returns {Chart|null}
- */
-function createRoutesTrendChart(canvasId, routes, maxRoutes) {
-    var ctx = document.getElementById(canvasId);
-    if (!ctx || !routes || routes.length === 0) return null;
-
-    maxRoutes = maxRoutes || 6;
-
-    // Bottom-to-top tier order on the categorical Y axis.
-    var tierOrder = ['failing', 'marginal', 'clear'];
-
-    function tierColor(tier) {
-        return ChartColors.quality[tier] || ChartColors.quality.failing;
-    }
-
-    // Sort by current matched_count desc; routes with null matched_count
-    // (disabled / never evaluated) sort to the end.
-    var sorted = routes.slice().sort(function(a, b) {
-        var am = a.matched_count || 0;
-        var bm = b.matched_count || 0;
-        return bm - am;
-    });
-    var top = sorted.slice(0, maxRoutes);
-
-    // Use the longest history as the X-axis label source (all routes
-    // share the same window in practice, but be defensive).
-    var labels = [];
-    for (var i = 0; i < top.length; i++) {
-        if (top[i].history && top[i].history.length > labels.length) {
-            labels = formatDateLabels(top[i].history);
-        }
-    }
-    if (labels.length === 0) return null;
-
-    var datasets = top.map(function(entry) {
-        var history = entry.history || [];
-        var avgTier = averageRouteTier(history);
-        return {
-            label: entry.from_label + ' \u2192 ' + entry.to_label,
-            data: history.map(function(d) { return routeQualityToTier(d.quality); }),
-            borderColor: tierColor(avgTier),
-            backgroundColor: 'transparent',
-            fill: false,
-            tension: 0.3,
-            cubicInterpolationMode: 'monotone',
-            pointRadius: 2,
-            pointHoverRadius: 5,
-            spanGaps: true,
-            _matched: history.map(function(d) { return d.matched_count || 0; })
-        };
-    });
-
-    var opts = createChartOptions(false);
-    // Replace the default numeric Y axis with a 3-tier categorical axis.
-    opts.scales.y = {
-        type: 'category',
-        labels: tierOrder,
-        reverse: true,
-        grid: { color: ChartColors.grid },
-        ticks: {
-            color: ChartColors.text,
-            callback: function(_value, index) {
-                var tier = tierOrder[index];
-                return (window.t && window.t('routes.quality_' + tier)) || tier;
-            }
-        }
-    };
-    // The default tooltip formatter calls formatNumber(ctx.parsed.y),
-    // which is wrong for categorical string values; emit tier + matched.
-    opts.plugins.tooltip.callbacks = {
-        title: function(items) { return items[0].label; },
-        label: function(ctx) {
-            var tier = tierOrder[ctx.parsed.y] || 'failing';
-            var tierLabel = (window.t && window.t('routes.quality_' + tier)) || tier;
-            var matched = (ctx.dataset._matched && ctx.dataset._matched[ctx.dataIndex]) || 0;
-            return ctx.dataset.label + ': ' + tierLabel + ' (' + matched + ')';
-        }
-    };
-
-    return new Chart(ctx, {
-        type: 'line',
-        data: { labels: labels, datasets: datasets },
-        options: opts
-    });
-}
-
-/**
- * Initialize dashboard charts (nodes, advertisements, messages, packets,
- * plus optional packet-breakdown stacked bars and routes overview).
- * Pass null for any data parameter to skip that chart.
- * @param {Object|null} nodeData - Node count data, or null to skip
- * @param {Object|null} advertData - Advertisement data, or null to skip
- * @param {Object|null} messageData - Message data, or null to skip
- * @param {Object|null} packetData - Raw-packet trend data, or null to skip
- * @param {Array|null} [eventTypeData] - Packet event-type breakdown buckets
- * @param {Array|null} [pathWidthData] - Packet path-width breakdown buckets
- * @param {Array|null} [routesData] - Routes overview ``routes`` array
- */
-function initDashboardCharts(nodeData, advertData, messageData, packetData, eventTypeData, pathWidthData, routesData) {
-    if (nodeData) {
-        createLineChart(
-            'nodeChart',
-            nodeData,
-            (window.t && window.t('common.total_entity', { entity: t('entities.nodes') })) || 'Total Nodes',
-            ChartColors.nodes,
-            ChartColors.nodesFill,
-            true
-        );
-    }
-
-    if (advertData) {
-        createLineChart(
-            'advertChart',
-            advertData,
-            (window.t && window.t('entities.advertisements')) || 'Advertisements',
-            ChartColors.adverts,
-            ChartColors.advertsFill,
-            true
-        );
-    }
-
-    if (messageData) {
-        createLineChart(
-            'messageChart',
-            messageData,
-            (window.t && window.t('entities.messages')) || 'Messages',
-            ChartColors.messages,
-            ChartColors.messagesFill,
-            true
-        );
-    }
-
-    if (packetData) {
-        createLineChart(
-            'packetChart',
-            packetData,
-            (window.t && window.t('entities.packets')) || 'Packets',
-            ChartColors.packets,
-            ChartColors.packetsFill,
-            true
-        );
-    }
-
-    if (eventTypeData && eventTypeData.length > 0) {
-        createStackedBarChart(
-            'packetEventTypeChart',
-            eventTypeData,
-            ChartColors.breakdown
-        );
-    }
-
-    if (pathWidthData && pathWidthData.length > 0) {
-        createStackedBarChart(
-            'packetPathWidthChart',
-            pathWidthData,
-            ChartColors.breakdown.slice(0, 3)
-        );
-    }
-
-    if (routesData && routesData.length > 0) {
-        createRoutesTrendChart('routesTrendChart', routesData);
-    }
-}
-
-/**
- * Create a per-route health status strip — single horizontal bar of N equal
- * colored day-segments.
- *
- * @param {string} canvasId - ID of the canvas element
- * @param {Object} routeData - RouteHistory payload with `data` array
- * @returns {Chart|null}
- */
-function createRouteDetailStrip(canvasId, routeData) {
-    var ctx = document.getElementById(canvasId);
-    if (!ctx || !routeData || !routeData.data || routeData.data.length === 0) {
-        return null;
-    }
-
-    var existing = Chart.getChart(ctx);
-    if (existing) existing.destroy();
-
-    var datasets = routeData.data.map(function(day) {
-        return {
-            label: day.date,
-            data: [1],
-            backgroundColor: ChartColors.quality[day.quality] || ChartColors.quality.no_coverage,
-            borderColor: ChartColors.quality[day.quality] || ChartColors.quality.no_coverage,
-            borderWidth: 1,
-            _quality: day.quality,
-            _matched_count: day.matched_count || 0
-        };
-    });
-
-    return new Chart(ctx, {
-        type: 'bar',
-        data: { labels: [''], datasets: datasets },
-        options: {
-            responsive: true,
-            maintainAspectRatio: false,
-            indexAxis: 'y',
-            plugins: {
-                legend: { display: false },
-                tooltip: {
-                    backgroundColor: ChartColors.tooltipBg,
-                    titleColor: ChartColors.tooltipText,
-                    bodyColor: ChartColors.tooltipText,
-                    borderColor: ChartColors.tooltipBorder,
-                    borderWidth: 1,
-                    callbacks: {
-                        title: function(ctx) { return ctx[0].dataset.label; },
-                        label: function(ctx) {
-                            var q = ctx.dataset._quality || 'unknown';
-                            var label = (window.t && window.t('routes.quality_' + q)) || q;
-                            return label + ' (' + ctx.dataset._matched_count + ')';
-                        }
-                    }
-                }
-            },
-            scales: {
-                x: { stacked: true, grid: { display: false }, ticks: { display: false } },
-                y: { stacked: true, grid: { display: false }, ticks: { display: false } }
-            },
-            interaction: { mode: 'nearest', intersect: true }
-        }
-    });
-}
diff --git a/src/meshcore_hub/web/static/js/spa-react/App.tsx b/src/meshcore_hub/web/static/js/spa-react/App.tsx
new file mode 100644
index 0000000..a99a0ef
--- /dev/null
+++ b/src/meshcore_hub/web/static/js/spa-react/App.tsx
@@ -0,0 +1,283 @@
+import { useEffect, useState } from "react";
+import { QueryClientProvider } from "@tanstack/react-query";
+import {
+  BrowserRouter,
+  Routes,
+  Route,
+  Navigate,
+  useLocation,
+  useParams,
+} from "react-router";
+import { createQueryClient } from "@/utils/queryClient";
+import { useAppConfig } from "@/context/AppConfigContext";
+import { ErrorBoundary } from "@/components/ErrorBoundary";
+import { Navbar } from "@/components/Navbar";
+import { Announcements } from "@/components/Announcements";
+import { Footer } from "@/components/Footer";
+import { HomePage } from "@/pages/Home";
+import { DashboardPage } from "@/pages/Dashboard";
+import { Nodes } from "@/pages/Nodes";
+import { NodeDetailPage } from "@/pages/NodeDetail";
+import { Channels } from "@/pages/Channels";
+import { RoutesPage } from "@/pages/Routes";
+import { Messages } from "@/pages/Messages";
+import { Advertisements } from "@/pages/Advertisements";
+import { Packets } from "@/pages/Packets";
+import { PacketDetail } from "@/pages/PacketDetail";
+import { PacketGroupDetail } from "@/pages/PacketGroupDetail";
+import { MapPage } from "@/pages/MapPage";
+import { Members } from "@/pages/Members";
+import { CustomPagePage } from "@/pages/CustomPage";
+import { Profile } from "@/pages/Profile";
+import { NotFound } from "@/pages/NotFound";
+import { Maintenance } from "@/pages/Maintenance";
+
+function useNavActiveState() {
+  const location = useLocation();
+  const config = useAppConfig();
+
+  useEffect(() => {
+    const pathname = location.pathname;
+
+    if (document.activeElement?.closest(".dropdown")) {
+      (document.activeElement as HTMLElement).blur();
+    }
+
+    if (!location.hash) {
+      window.scrollTo(0, 0);
+    }
+
+    const networkName = config.network_name || "MeshCore Network";
+    const features = config.features ?? {};
+    const t = window.t;
+    const compose = (key: string) => `${t(key)} - ${networkName}`;
+
+    const titles: Record = { "/": networkName };
+    if (features.dashboard !== false) titles["/dashboard"] = compose("entities.dashboard");
+    if (features.nodes !== false) titles["/nodes"] = compose("entities.nodes");
+    if (features.channels !== false) titles["/channels"] = compose("entities.channels");
+    if (features.routes !== false) titles["/routes"] = compose("entities.routes");
+    if (features.messages !== false) titles["/messages"] = compose("entities.messages");
+    if (features.advertisements !== false) titles["/advertisements"] = compose("entities.advertisements");
+    if (features.packets !== false) titles["/packets"] = compose("entities.packets");
+    if (features.map !== false) titles["/map"] = compose("entities.map");
+    if (features.members !== false) titles["/members"] = compose("entities.members");
+    titles["/profile"] = compose("links.profile");
+
+    if (titles[pathname]) {
+      document.title = titles[pathname];
+    } else if (pathname.startsWith("/nodes/")) {
+      document.title = compose("entities.node_detail");
+    } else {
+      document.title = networkName;
+    }
+  }, [location.pathname, location.hash, config]);
+}
+
+function ShortLinkRedirect() {
+  const { prefix } = useParams();
+  return ;
+}
+
+function AppRoutes() {
+  const config = useAppConfig();
+  const features = config.features ?? {};
+  const maintenanceMode = config.system_maintenance === true;
+
+  useNavActiveState();
+
+  if (maintenanceMode) {
+    return (
+      
+        } />
+      
+    );
+  }
+
+  return (
+    
+      
+            
+          
+        }
+      />
+      {features.dashboard !== false && (
+        
+              
+            
+          }
+        />
+      )}
+      {features.nodes !== false && (
+        <>
+          
+                
+              
+            }
+          />
+          
+                
+              
+            }
+          />
+          } />
+        
+      )}
+      {features.channels !== false && (
+        
+              
+            
+          }
+        />
+      )}
+      {features.routes !== false && (
+        
+              
+            
+          }
+        />
+      )}
+      {features.messages !== false && (
+        
+              
+            
+          }
+        />
+      )}
+      {features.advertisements !== false && (
+        
+              
+            
+          }
+        />
+      )}
+      {features.packets !== false && (
+        <>
+          
+                
+              
+            }
+          />
+          
+                
+              
+            }
+          />
+          
+                
+              
+            }
+          />
+        
+      )}
+      {features.map !== false && (
+        
+              
+            
+          }
+        />
+      )}
+      {features.members !== false && (
+        
+              
+            
+          }
+        />
+      )}
+      {features.pages !== false && (
+        
+              
+            
+          }
+        />
+      )}
+      {config.oidc_enabled && (
+        <>
+          
+                
+              
+            }
+          />
+          
+                
+              
+            }
+          />
+        
+      )}
+      } />
+    
+  );
+}
+
+function Shell() {
+  return (
+    <>
+      
+      
+      
+ +
+