From 8718bf3563508285efd076ae0b10bdca89746bcf Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sat, 23 May 2026 21:49:14 -0400 Subject: [PATCH] Initial commit --- .dockerignore | 20 + .env.example | 5 + .github/workflows/docker-publish.yml | 47 + .gitignore | 28 + Dockerfile | 16 + README.md | 99 + colour-palettes/palette-amber.html | 621 +++ colour-palettes/palette-arctic.html | 223 + colour-palettes/palette-charcoal-white.html | 148 + colour-palettes/palette-command.html | 149 + colour-palettes/palette-cyberpunk.html | 228 + colour-palettes/palette-earth.html | 253 + colour-palettes/palette-electric-lime.html | 149 + colour-palettes/palette-indigo-clean.html | 148 + colour-palettes/palette-midnight-gold.html | 335 ++ colour-palettes/palette-neutral-blue.html | 148 + colour-palettes/palette-phosphor.html | 603 +++ colour-palettes/palette-radar.html | 365 ++ colour-palettes/palette-signal-orange.html | 222 + colour-palettes/palette-slate-rose.html | 254 + colour-palettes/palette-synthwave.html | 149 + colour-palettes/palette-teal.html | 604 +++ docker/Caddyfile | 12 + docker/Caddyfile.proxy | 3 + docker/docker-compose.yml | 20 + docker/docker-entrypoint.sh | 12 + eslint.config.js | 22 + index.html | 19 + package-lock.json | 4325 +++++++++++++++++ package.json | 39 + public/favicon.svg | 1 + public/icons.svg | 24 + public/themes.json | 338 ++ src/App.tsx | 83 + src/api/client.ts | 60 + src/api/ws-manager.ts | 223 + src/components/AppShell.tsx | 240 + src/components/Badge.tsx | 12 + src/components/Dropdown.tsx | 26 + src/components/ErrorBoundary.tsx | 37 + src/components/FilterBar.tsx | 96 + src/components/HopBadge.tsx | 26 + src/components/MultiSelectDropdown.tsx | 161 + src/components/SearchBar.tsx | 119 + src/components/badge-utils.ts | 37 + src/features/channels/ChannelList.tsx | 7 + src/features/channels/types.ts | 14 + src/features/map/MapView.tsx | 7 + src/features/map/types.ts | 16 + src/features/nodes/NodeTable.tsx | 7 + src/features/nodes/types.ts | 11 + src/features/observers/ObserverTable.tsx | 7 + src/features/observers/types.ts | 13 + src/features/packets/ObservationCard.tsx | 65 + src/features/packets/PacketAnalyzerDrawer.tsx | 205 + src/features/packets/PacketList.tsx | 168 + src/features/packets/PacketRow.tsx | 83 + src/features/packets/PacketVirtualList.tsx | 193 + src/features/packets/packet-structure.tsx | 355 ++ src/features/packets/payload-renderers.tsx | 675 +++ src/features/packets/types.ts | 19 + src/features/packets/usePacketFilters.ts | 140 + src/features/packets/usePackets.ts | 219 + src/features/stats/StatsOverview.tsx | 7 + src/features/stats/types.ts | 12 + src/hooks/useClickOutside.ts | 16 + src/hooks/useRegion.tsx | 29 + src/hooks/useTheme.tsx | 58 + src/hooks/useWsHandlers.ts | 21 + src/hooks/useWsStatus.ts | 11 + src/index.css | 55 + src/lib/constants.ts | 14 + src/lib/formatters.ts | 50 + src/lib/themes.ts | 49 + src/main.tsx | 10 + src/types/api.ts | 97 + src/types/enums.ts | 53 + src/types/ws.ts | 119 + tests/api/ws-manager.test.ts | 186 + tests/lib/formatters.test.ts | 71 + tests/setup.ts | 7 + tsconfig.app.json | 34 + tsconfig.json | 7 + tsconfig.node.json | 24 + vite.config.ts | 12 + 85 files changed, 13895 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .github/workflows/docker-publish.yml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 colour-palettes/palette-amber.html create mode 100644 colour-palettes/palette-arctic.html create mode 100644 colour-palettes/palette-charcoal-white.html create mode 100644 colour-palettes/palette-command.html create mode 100644 colour-palettes/palette-cyberpunk.html create mode 100644 colour-palettes/palette-earth.html create mode 100644 colour-palettes/palette-electric-lime.html create mode 100644 colour-palettes/palette-indigo-clean.html create mode 100644 colour-palettes/palette-midnight-gold.html create mode 100644 colour-palettes/palette-neutral-blue.html create mode 100644 colour-palettes/palette-phosphor.html create mode 100644 colour-palettes/palette-radar.html create mode 100644 colour-palettes/palette-signal-orange.html create mode 100644 colour-palettes/palette-slate-rose.html create mode 100644 colour-palettes/palette-synthwave.html create mode 100644 colour-palettes/palette-teal.html create mode 100644 docker/Caddyfile create mode 100644 docker/Caddyfile.proxy create mode 100644 docker/docker-compose.yml create mode 100644 docker/docker-entrypoint.sh create mode 100644 eslint.config.js create mode 100644 index.html create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 public/favicon.svg create mode 100644 public/icons.svg create mode 100644 public/themes.json create mode 100644 src/App.tsx create mode 100644 src/api/client.ts create mode 100644 src/api/ws-manager.ts create mode 100644 src/components/AppShell.tsx create mode 100644 src/components/Badge.tsx create mode 100644 src/components/Dropdown.tsx create mode 100644 src/components/ErrorBoundary.tsx create mode 100644 src/components/FilterBar.tsx create mode 100644 src/components/HopBadge.tsx create mode 100644 src/components/MultiSelectDropdown.tsx create mode 100644 src/components/SearchBar.tsx create mode 100644 src/components/badge-utils.ts create mode 100644 src/features/channels/ChannelList.tsx create mode 100644 src/features/channels/types.ts create mode 100644 src/features/map/MapView.tsx create mode 100644 src/features/map/types.ts create mode 100644 src/features/nodes/NodeTable.tsx create mode 100644 src/features/nodes/types.ts create mode 100644 src/features/observers/ObserverTable.tsx create mode 100644 src/features/observers/types.ts create mode 100644 src/features/packets/ObservationCard.tsx create mode 100644 src/features/packets/PacketAnalyzerDrawer.tsx create mode 100644 src/features/packets/PacketList.tsx create mode 100644 src/features/packets/PacketRow.tsx create mode 100644 src/features/packets/PacketVirtualList.tsx create mode 100644 src/features/packets/packet-structure.tsx create mode 100644 src/features/packets/payload-renderers.tsx create mode 100644 src/features/packets/types.ts create mode 100644 src/features/packets/usePacketFilters.ts create mode 100644 src/features/packets/usePackets.ts create mode 100644 src/features/stats/StatsOverview.tsx create mode 100644 src/features/stats/types.ts create mode 100644 src/hooks/useClickOutside.ts create mode 100644 src/hooks/useRegion.tsx create mode 100644 src/hooks/useTheme.tsx create mode 100644 src/hooks/useWsHandlers.ts create mode 100644 src/hooks/useWsStatus.ts create mode 100644 src/index.css create mode 100644 src/lib/constants.ts create mode 100644 src/lib/formatters.ts create mode 100644 src/lib/themes.ts create mode 100644 src/main.tsx create mode 100644 src/types/api.ts create mode 100644 src/types/enums.ts create mode 100644 src/types/ws.ts create mode 100644 tests/api/ws-manager.test.ts create mode 100644 tests/lib/formatters.test.ts create mode 100644 tests/setup.ts create mode 100644 tsconfig.app.json create mode 100644 tsconfig.json create mode 100644 tsconfig.node.json create mode 100644 vite.config.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1c277d2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,20 @@ +node_modules +.git +dist +build +coverage +docs +tests +colour-palettes + +.env +.env.* +!.env.example + +.DS_Store +.claude +.github +.gitignore +.vscode +.idea +*.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a9b3a01 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +# Tower REST API base URL +VITE_API_BASE=http://localhost:8080/api/v1 + +# WebSocket endpoint for live packets +VITE_WS_URL=ws://localhost:8080/ws diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..dd201a6 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,47 @@ +name: Build and Publish Docker Image + +on: + push: + branches: [main] + tags: ["v*"] + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build-and-push: + runs-on: ubuntu-latest + + permissions: + contents: read + packages: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=raw,value=latest,enable={{is_default_branch}} + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c2da866 --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +# Dependencies +node_modules/ + +# Build +dist/ +build/ +.next/ +.nuxt/ + +# Environment +.env +.env.* +!.env.example + +# IDE +.idea/ +.vscode/ +*.swp + +# Testing +coverage/ + +# Claude +.claude/ + +# OS +.DS_Store +Thumbs.db diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3671bf8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +FROM node:24-alpine AS build +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci +COPY . . +ENV VITE_API_BASE=__VITE_API_BASE__ +ENV VITE_WS_URL=__VITE_WS_URL__ +RUN npm run build + +FROM caddy:2-alpine +COPY docker/Caddyfile /etc/caddy/Caddyfile +COPY docker/docker-entrypoint.sh /docker-entrypoint.sh +RUN chmod +x /docker-entrypoint.sh +COPY --from=build /app/dist /srv +ENTRYPOINT ["/docker-entrypoint.sh"] +CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..a16d75a --- /dev/null +++ b/README.md @@ -0,0 +1,99 @@ +# MeshCore Tower Web + +Real-time LoRa mesh packet analyzer. Desktop-first, dark-mode-primary, dense information display for radio hobbyists. + +Built with React 19, TypeScript, Tailwind CSS 4, TanStack Query, and TanStack Virtual. + +## Deployment + +### 1. Copy the `docker/` folder to your server + +```bash +scp -r docker/ user@your-server:/opt/docker/tower-web +``` + +### 2. Create a `.env` file + +```bash +cd /opt/docker/tower-web +cat > .env << 'EOF' +DOMAIN=dev.meshcore.ca +VITE_API_BASE=https://dev.meshcore.ca/api/v1 +VITE_WS_URL=wss://dev.meshcore.ca/ws +EOF +``` + +| Variable | Description | +|---|---| +| `DOMAIN` | Domain for HTTPS (Caddy auto-provisions Let's Encrypt certs) | +| `VITE_API_BASE` | Backend REST API base URL | +| `VITE_WS_URL` | Backend WebSocket URL | + +### 3. Authenticate with GitHub Container Registry + +```bash +docker login ghcr.io -u YOUR_GITHUB_USERNAME +``` + +Use a Personal Access Token (classic) with `read:packages` scope as the password. You only need to do this once. + +### 4. Start the services + +```bash +docker compose up -d +``` + +Caddy will automatically obtain a TLS certificate for your domain. Ensure DNS is pointed at your server before starting. + +## Local Development + +```bash +npm install +cp .env.example .env # edit with your backend URLs +npm run dev # starts Vite dev server at http://localhost:5173 +``` + +### Commands + +| Command | Description | +|---|---| +| `npm run dev` | Start dev server | +| `npm run build` | Type-check and build for production | +| `npm run preview` | Preview production build locally | +| `npm run lint` | Run ESLint | +| `npx vitest run` | Run tests | +| `npx tsc --noEmit` | Type-check without emitting | + +## Project Structure + +``` +docker/ + docker-compose.yml # production deployment compose file + Caddyfile # internal Caddy config (static file serving) + Caddyfile.proxy # reverse proxy config (HTTPS termination) + docker-entrypoint.sh # runtime env var injection +Dockerfile # multi-stage build (Node + Caddy) +src/ + api/ + client.ts # typed REST client (fetch wrapper) + ws-manager.ts # WebSocket connection, reconnect, subscription management + components/ # shared UI components + features/ # feature modules (packets, nodes, channels, map, stats) + hooks/ # React hooks (region, theme, WebSocket) + lib/ # constants, formatters, theme utilities + types/ # TypeScript types and enums + App.tsx # providers + routing + WS init + main.tsx # entry point + index.css # Tailwind setup, theme tokens, animations +``` + +## Architecture + +- **Region-driven**: All data queries and WS subscriptions are scoped to an IATA region code. Changing region resets the cache and resubscribes. +- **Live + historical merge**: WebSocket pushes live packets into a `LivePacketStore` buffer (capped at 500). Historical data comes from cursor-paginated REST via `useInfiniteQuery` (max 20 pages). Both are merged and deduped at render time. +- **Client-side filtering**: Filters are not part of the query key. The cache holds all packets for the current region; filters are applied via `useMemo`. Toggling a filter is instant with no refetch. +- **Reconnect with jitter**: Exponential backoff with +/-25% random jitter prevents thundering herd on server bounce. + +## License + +See repository root. diff --git a/colour-palettes/palette-amber.html b/colour-palettes/palette-amber.html new file mode 100644 index 0000000..3968de3 --- /dev/null +++ b/colour-palettes/palette-amber.html @@ -0,0 +1,621 @@ + + + + + +MeshCore Tower — Amber Palette + + + + +
+ OPTION A — WARM AMBER + Oscilloscope / VFD aesthetic · warm near-black base · amber primary · green accents +
+ +
+
Primary
+
Secondary
+
Danger
+
Warning
+
Base
+
Surface
+
Bright
+
Muted
+
+ + +
+
TOWER v0.1
+
YOW
+
+ +
+
Packets
+
Nodes
+
Observers
+
Channels
+
Stats
+
Map
+
+ +
+
All types
+
Advert
+
Text
+
Group
+
Trace
+
Ack
+
+
All routes
+
Flood
+
Direct
+
+ +
▲ 47 new packets · click to scroll to top
+ +
+ +
+
+ + 9E9B7D6A + Advert + Advert "WW7STR/PugetMesh Cougar" + ×15 +
+
+ Flood + · + 02:37 PM + · + FlightlessDt + · + KEH +
+ +
+
+
+ FlightlessDt + KEH + 02:37:36 +
+
+ SNR 10.75 + RSSI -98 + Prop 1.936s + Hops 1 +
+
+ Path: + YOW_Kanata + + FlightlessDt +
+ +
+ +
+
+ NodeRunner + SEA + 02:37:38 +
+
+ SNR 7.20 + RSSI -105 + Prop 4.122s + Hops 2 +
+
+ Path: + YOW_Kanata + + AE9B (2 candidates) + + NodeRunner +
+ +
+
+
+ + +
+
+ + 3FA1C20E + Group + Group text on #ottawa + ×8 +
+
+ Flood + · + 02:36 PM + · + Hull_Hospital + · + YOW +
+
+ + +
+
+ + B7D44F19 + Trace + Trace 4-byte path, 6 hops + ×3 +
+
+ Flood + · + 02:36 PM + · + GatineauPeak + · + YOW +
+
+ + +
+
+ + 1CE88A03 + Ack + Acknowledgment + ×2 +
+
+ Direct + · + 02:35 PM + · + RobaStick_YOW + · + YOW +
+
+ + +
+
+ + D9F21B7C + Text + Plain text message + ×12 +
+
+ Flood + · + 02:34 PM + · + Bells_Corners + · + YOW +
+
+ + +
+
+ + 44AE0FD2 + Request + Request dest=A3F1 src=9E2B + ×5 +
+
+ Direct + · + 02:33 PM + · + Hull_Hospital + · + YOW +
+
+ + +
+
+ + E501CAFE + Advert + Advert "VE3XYZ/Ottawa_South" + ×22 +
+
+ Flood + · + 02:32 PM + · + GatineauPeak + · + YOW +
+
+
+ +
+
WebSocket connected · mqtt1 + mqtt2
+
1,247 packets · 34 observers · 189 nodes
+
+ + + diff --git a/colour-palettes/palette-arctic.html b/colour-palettes/palette-arctic.html new file mode 100644 index 0000000..bba2d4d --- /dev/null +++ b/colour-palettes/palette-arctic.html @@ -0,0 +1,223 @@ + + + + + +MeshCore Tower — Arctic / Frost Palette + + + + +
+ OPTION I — ARCTIC / FROST + Satellite comms aesthetic · icy cyan-blue on deep navy · violet accents · crisp and clean +
Cold, precise, high-contrast. Think NOAA satellite feeds and arctic research station displays. The cyan primary is extremely readable on dark backgrounds and the violet secondary adds distinctive personality without clashing.
+
+ +
+
Primary
+
Secondary
+
Good
+
Danger
+
Base
+
Surface
+
Bright
+
Muted
+
+ +
+
TOWER v0.1
+
+
LIVE
+
SCOPE YOW
+
+
+
+
Packets
Nodes
Observers
Channels
Stats
Map
+
+
+
All types
Advert
Text
Group
Trace
Ack
+
+
All routes
Flood
Direct
+
+
▲ 47 new packets · click to scroll to top
+ +
+
+
9E9B7D6AAdvertAdvert "WW7STR/PugetMesh Cougar"×15
+
Flood·02:37 PM·FlightlessDt·KEH
+
+
+
FlightlessDtKEH02:37:36
+
+
SNR10.75
+
RSSI-98
+
Prop1.936s
+
Hops1
+
+
PathYOW_KanataFlightlessDt
+ +
+
+
NodeRunnerSEA02:37:38
+
+
SNR7.20
+
RSSI-105
+
Prop4.122s
+
Hops2
+
+
PathYOW_KanataAE9B (2 candidates)NodeRunner
+ +
+
+
+
3FA1C20EGroupGroup text on #ottawa×8
Flood·02:36 PM·Hull_Hospital·YOW
+
B7D44F19TraceTrace 4-byte path, 6 hops×3
Flood·02:36 PM·GatineauPeak·YOW
+
1CE88A03AckAcknowledgment×2
Direct·02:35 PM·RobaStick_YOW·YOW
+
D9F21B7CTextPlain text message×12
Flood·02:34 PM·Bells_Corners·YOW
+
44AE0FD2RequestRequest dest=A3F1 src=9E2B×5
Direct·02:33 PM·Hull_Hospital·YOW
+
E501CAFEAdvertAdvert "VE3XYZ/Ottawa_South"×22
Flood·02:32 PM·GatineauPeak·YOW
+
+ +
+
1,247 packets
34 observers
189 nodes
+
mqtt1 + mqtt2
+
+ + diff --git a/colour-palettes/palette-charcoal-white.html b/colour-palettes/palette-charcoal-white.html new file mode 100644 index 0000000..0a4fd95 --- /dev/null +++ b/colour-palettes/palette-charcoal-white.html @@ -0,0 +1,148 @@ + + + + + +MeshCore Tower — Charcoal / White Palette + + + +
+ OPTION O — CHARCOAL / WHITE + Minimal monochrome · white-on-charcoal · color only for semantics (status, types, signals) +
Maximum readability, zero palette fatigue. White text, pure gray scale, color reserved exclusively for meaning: green=good, red=bad, yellow=warning, blue=links. Like a Bloomberg terminal or VS Code's default dark. The data speaks; the chrome stays silent.
+
+
+
Primary
+
Accent
+
Good
+
Danger
+
Base
+
Surface
+
Normal
+
Muted
+
+
Tower v0.1
LIVE
SCOPE YOW
+
Packets
Nodes
Observers
Channels
Stats
Map
+
All types
Advert
Text
Group
Trace
Ack
All routes
Flood
Direct
+
▲ 47 new packets · click to scroll to top
+
+
+
9E9B7D6AAdvertAdvert "WW7STR/PugetMesh Cougar"×15
+
Flood·02:37 PM·FlightlessDt·KEH
+
+
FlightlessDtKEH02:37:36
SNR10.75
RSSI-98
Prop1.936s
Hops1
PathYOW_KanataFlightlessDt
+
NodeRunnerSEA02:37:38
SNR7.20
RSSI-105
Prop4.122s
Hops2
PathYOW_KanataAE9B (2 candidates)NodeRunner
+
+
+
3FA1C20EGroupGroup text on #ottawa×8
Flood·02:36 PM·Hull_Hospital·YOW
+
B7D44F19TraceTrace 4-byte path, 6 hops×3
Flood·02:36 PM·GatineauPeak·YOW
+
1CE88A03AckAcknowledgment×2
Direct·02:35 PM·RobaStick_YOW·YOW
+
D9F21B7CTextPlain text message×12
Flood·02:34 PM·Bells_Corners·YOW
+
44AE0FD2RequestRequest dest=A3F1 src=9E2B×5
Direct·02:33 PM·Hull_Hospital·YOW
+
E501CAFEAdvertAdvert "VE3XYZ/Ottawa_South"×22
Flood·02:32 PM·GatineauPeak·YOW
+
+
1,247 packets
34 observers
189 nodes
mqtt1 + mqtt2
+ + diff --git a/colour-palettes/palette-command.html b/colour-palettes/palette-command.html new file mode 100644 index 0000000..02087c9 --- /dev/null +++ b/colour-palettes/palette-command.html @@ -0,0 +1,149 @@ + + + + + +MeshCore Tower — Command Red Palette + + + +
+ OPTION J — COMMAND RED + Military command center aesthetic · muted crimson on deep purple-black · slate-blue secondary +
Red reads as "monitoring active systems" without being alarming at this saturation. The purple-black base and lavender text give it a distinct look that no other mesh tool has. Blue secondary balances the warmth.
+
+
+
Primary
+
Secondary
+
Good
+
Danger
+
Base
+
Surface
+
Bright
+
Muted
+
+
TOWER v0.1
LIVE
SCOPE YOW
+
Packets
Nodes
Observers
Channels
Stats
Map
+
All types
Advert
Text
Group
Trace
Ack
All routes
Flood
Direct
+
▲ 47 new packets · click to scroll to top
+
+
+
9E9B7D6AAdvertAdvert "WW7STR/PugetMesh Cougar"×15
+
Flood·02:37 PM·FlightlessDt·KEH
+
+
FlightlessDtKEH02:37:36
SNR10.75
RSSI-98
Prop1.936s
Hops1
PathYOW_KanataFlightlessDt
+
NodeRunnerSEA02:37:38
SNR7.20
RSSI-105
Prop4.122s
Hops2
PathYOW_KanataAE9B (2 candidates)NodeRunner
+
+
+
3FA1C20EGroupGroup text on #ottawa×8
Flood·02:36 PM·Hull_Hospital·YOW
+
B7D44F19TraceTrace 4-byte path, 6 hops×3
Flood·02:36 PM·GatineauPeak·YOW
+
1CE88A03AckAcknowledgment×2
Direct·02:35 PM·RobaStick_YOW·YOW
+
D9F21B7CTextPlain text message×12
Flood·02:34 PM·Bells_Corners·YOW
+
44AE0FD2RequestRequest dest=A3F1 src=9E2B×5
Direct·02:33 PM·Hull_Hospital·YOW
+
E501CAFEAdvertAdvert "VE3XYZ/Ottawa_South"×22
Flood·02:32 PM·GatineauPeak·YOW
+
+
1,247 packets
34 observers
189 nodes
mqtt1 + mqtt2
+ + diff --git a/colour-palettes/palette-cyberpunk.html b/colour-palettes/palette-cyberpunk.html new file mode 100644 index 0000000..cf04f0b --- /dev/null +++ b/colour-palettes/palette-cyberpunk.html @@ -0,0 +1,228 @@ + + + + + +MeshCore Tower — Cyberpunk Palette + + + + +
+ OPTION P — CYBERPUNK + Neon on void · electric cyan + hot pink on ultra-dark purple-black · subtle glow effects +
Night City control room. Cyan primary text glows against a near-black purple base, hot pink accents on expanded items and links. Restrained glow effects on key elements — enough to feel electric without burning retinas at 3am. The neon is the signal, the void is the substrate.
+
+ +
+
Primary
+
Secondary
+
Good
+
Danger
+
Base
+
Surface
+
Bright
+
Muted
+
+ +
+
TOWER v0.1
+
+
LIVE
+
SCOPE YOW
+
+
+
+
Packets
Nodes
Observers
Channels
Stats
Map
+
+
+
All types
Advert
Text
Group
Trace
Ack
+
|
+
All routes
Flood
Direct
+
+
▲ 47 new packets · click to scroll to top
+ +
+
+
9E9B7D6AAdvertAdvert "WW7STR/PugetMesh Cougar"×15
+
Flood·02:37 PM·FlightlessDt·KEH
+
+
+
FlightlessDtKEH02:37:36
+
+
SNR10.75
+
RSSI-98
+
Prop1.936s
+
Hops1
+
+
PathYOW_KanataFlightlessDt
+ +
+
+
NodeRunnerSEA02:37:38
+
+
SNR7.20
+
RSSI-105
+
Prop4.122s
+
Hops2
+
+
PathYOW_KanataAE9B (2 candidates)NodeRunner
+ +
+
+
+
3FA1C20EGroupGroup text on #ottawa×8
Flood·02:36 PM·Hull_Hospital·YOW
+
B7D44F19TraceTrace 4-byte path, 6 hops×3
Flood·02:36 PM·GatineauPeak·YOW
+
1CE88A03AckAcknowledgment×2
Direct·02:35 PM·RobaStick_YOW·YOW
+
D9F21B7CTextPlain text message×12
Flood·02:34 PM·Bells_Corners·YOW
+
44AE0FD2RequestRequest dest=A3F1 src=9E2B×5
Direct·02:33 PM·Hull_Hospital·YOW
+
E501CAFEAdvertAdvert "VE3XYZ/Ottawa_South"×22
Flood·02:32 PM·GatineauPeak·YOW
+
+ +
+
1,247 packets
34 observers
189 nodes
+
mqtt1 + mqtt2
+
+ + diff --git a/colour-palettes/palette-earth.html b/colour-palettes/palette-earth.html new file mode 100644 index 0000000..666eae9 --- /dev/null +++ b/colour-palettes/palette-earth.html @@ -0,0 +1,253 @@ + + + + + +MeshCore Tower — Earth / Sandstone Palette + + + + +
+ OPTION F — EARTH / SANDSTONE + Warm chocolate base · terracotta/sandstone primary · sage green secondary · desert radio outpost +
+ +
+
Primary
+
Secondary
+
Danger
+
Warning
+
Base
+
Surface
+
Bright
+
Muted
+
+ +
+
TOWER v0.1
+
YOW
+
+
+
Packets
+
Nodes
+
Observers
+
Channels
+
Stats
+
Map
+
+
+
All types
+
Advert
+
Text
+
Group
+
Trace
+
Ack
+
+
All routes
+
Flood
+
Direct
+
+
▲ 47 new packets · click to scroll to top
+ +
+
+
+ + 9E9B7D6A + Advert + Advert "WW7STR/PugetMesh Cougar" + ×15 +
+
Flood·02:37 PM·FlightlessDt·KEH
+
+
+
FlightlessDtKEH02:37:36
+
+ SNR 10.75 + RSSI -98 + Prop 1.936s + Hops 1 +
+
Path:YOW_KanataFlightlessDt
+ +
+
+
NodeRunnerSEA02:37:38
+
+ SNR 7.20 + RSSI -105 + Prop 4.122s + Hops 2 +
+
Path:YOW_KanataAE9B (2 candidates)NodeRunner
+ +
+
+
+ +
+
3FA1C20EGroupGroup text on #ottawa×8
+
Flood·02:36 PM·Hull_Hospital·YOW
+
+
+
B7D44F19TraceTrace 4-byte path, 6 hops×3
+
Flood·02:36 PM·GatineauPeak·YOW
+
+
+
1CE88A03AckAcknowledgment×2
+
Direct·02:35 PM·RobaStick_YOW·YOW
+
+
+
D9F21B7CTextPlain text message×12
+
Flood·02:34 PM·Bells_Corners·YOW
+
+
+
44AE0FD2RequestRequest dest=A3F1 src=9E2B×5
+
Direct·02:33 PM·Hull_Hospital·YOW
+
+
+
E501CAFEAdvertAdvert "VE3XYZ/Ottawa_South"×22
+
Flood·02:32 PM·GatineauPeak·YOW
+
+
+ +
+
WebSocket connected · mqtt1 + mqtt2
+
1,247 packets · 34 observers · 189 nodes
+
+ + diff --git a/colour-palettes/palette-electric-lime.html b/colour-palettes/palette-electric-lime.html new file mode 100644 index 0000000..079ea11 --- /dev/null +++ b/colour-palettes/palette-electric-lime.html @@ -0,0 +1,149 @@ + + + + + +MeshCore Tower — Electric Lime Palette + + + +
+ OPTION K — ELECTRIC LIME + Night-vision / tactical display aesthetic · chartreuse lime on olive-black · cyan secondary +
Lime-green on dark olive is what night-vision and heads-up displays use because the human eye has peak sensitivity in this range. Extremely readable, extremely distinctive, unmistakably technical.
+
+
+
Primary
+
Secondary
+
Good
+
Danger
+
Base
+
Surface
+
Bright
+
Muted
+
+
TOWER v0.1
LIVE
SCOPE YOW
+
Packets
Nodes
Observers
Channels
Stats
Map
+
All types
Advert
Text
Group
Trace
Ack
All routes
Flood
Direct
+
▲ 47 new packets · click to scroll to top
+
+
+
9E9B7D6AAdvertAdvert "WW7STR/PugetMesh Cougar"×15
+
Flood·02:37 PM·FlightlessDt·KEH
+
+
FlightlessDtKEH02:37:36
SNR10.75
RSSI-98
Prop1.936s
Hops1
PathYOW_KanataFlightlessDt
+
NodeRunnerSEA02:37:38
SNR7.20
RSSI-105
Prop4.122s
Hops2
PathYOW_KanataAE9B (2 candidates)NodeRunner
+
+
+
3FA1C20EGroupGroup text on #ottawa×8
Flood·02:36 PM·Hull_Hospital·YOW
+
B7D44F19TraceTrace 4-byte path, 6 hops×3
Flood·02:36 PM·GatineauPeak·YOW
+
1CE88A03AckAcknowledgment×2
Direct·02:35 PM·RobaStick_YOW·YOW
+
D9F21B7CTextPlain text message×12
Flood·02:34 PM·Bells_Corners·YOW
+
44AE0FD2RequestRequest dest=A3F1 src=9E2B×5
Direct·02:33 PM·Hull_Hospital·YOW
+
E501CAFEAdvertAdvert "VE3XYZ/Ottawa_South"×22
Flood·02:32 PM·GatineauPeak·YOW
+
+
1,247 packets
34 observers
189 nodes
mqtt1 + mqtt2
+ + diff --git a/colour-palettes/palette-indigo-clean.html b/colour-palettes/palette-indigo-clean.html new file mode 100644 index 0000000..702b198 --- /dev/null +++ b/colour-palettes/palette-indigo-clean.html @@ -0,0 +1,148 @@ + + + + + +MeshCore Tower — Indigo Clean Palette + + + +
+ OPTION N — INDIGO CLEAN + Modern app aesthetic · indigo primary on cool dark base · teal + emerald accents +
Like Linear, Vercel, or Arc — a polished modern dark theme with indigo as the signature color. Slightly warmer than plain blue, gives it just enough personality to not feel generic. Clean rounded corners, generous spacing.
+
+
+
Primary
+
Secondary
+
Good
+
Danger
+
Base
+
Surface
+
Bright
+
Muted
+
+
Tower v0.1
LIVE
SCOPE YOW
+
Packets
Nodes
Observers
Channels
Stats
Map
+
All types
Advert
Text
Group
Trace
Ack
All routes
Flood
Direct
+
▲ 47 new packets · click to scroll to top
+
+
+
9E9B7D6AAdvertAdvert "WW7STR/PugetMesh Cougar"×15
+
Flood·02:37 PM·FlightlessDt·KEH
+
+
FlightlessDtKEH02:37:36
SNR10.75
RSSI-98
Prop1.936s
Hops1
PathYOW_KanataFlightlessDt
+
NodeRunnerSEA02:37:38
SNR7.20
RSSI-105
Prop4.122s
Hops2
PathYOW_KanataAE9B (2 candidates)NodeRunner
+
+
+
3FA1C20EGroupGroup text on #ottawa×8
Flood·02:36 PM·Hull_Hospital·YOW
+
B7D44F19TraceTrace 4-byte path, 6 hops×3
Flood·02:36 PM·GatineauPeak·YOW
+
1CE88A03AckAcknowledgment×2
Direct·02:35 PM·RobaStick_YOW·YOW
+
D9F21B7CTextPlain text message×12
Flood·02:34 PM·Bells_Corners·YOW
+
44AE0FD2RequestRequest dest=A3F1 src=9E2B×5
Direct·02:33 PM·Hull_Hospital·YOW
+
E501CAFEAdvertAdvert "VE3XYZ/Ottawa_South"×22
Flood·02:32 PM·GatineauPeak·YOW
+
+
1,247 packets
34 observers
189 nodes
mqtt1 + mqtt2
+ + diff --git a/colour-palettes/palette-midnight-gold.html b/colour-palettes/palette-midnight-gold.html new file mode 100644 index 0000000..17bc1ff --- /dev/null +++ b/colour-palettes/palette-midnight-gold.html @@ -0,0 +1,335 @@ + + + + + +MeshCore Tower — Midnight / Gold Palette + + + + +
+ OPTION D — MIDNIGHT / GOLD + Deep navy base · warm gold primary · blue secondary · instrument panel feel +
+ +
+
Primary
+
Secondary
+
Good
+
Danger
+
Base
+
Surface
+
Bright
+
Muted
+
+ +
+
TOWER v0.1
+
YOW
+
+
+
Packets
+
Nodes
+
Observers
+
Channels
+
Stats
+
Map
+
+
+
All types
+
Advert
+
Text
+
Group
+
Trace
+
Ack
+
+
All routes
+
Flood
+
Direct
+
+
▲ 47 new packets · click to scroll to top
+ +
+
+
+ + 9E9B7D6A + Advert + Advert "WW7STR/PugetMesh Cougar" + ×15 +
+
+ Flood·02:37 PM·FlightlessDt·KEH +
+
+
+
FlightlessDtKEH02:37:36
+
+ SNR 10.75 + RSSI -98 + Prop 1.936s + Hops 1 +
+
Path:YOW_KanataFlightlessDt
+ +
+
+
NodeRunnerSEA02:37:38
+
+ SNR 7.20 + RSSI -105 + Prop 4.122s + Hops 2 +
+
Path:YOW_KanataAE9B (2 candidates)NodeRunner
+ +
+
+
+ +
+
3FA1C20EGroupGroup text on #ottawa×8
+
Flood·02:36 PM·Hull_Hospital·YOW
+
+
+
B7D44F19TraceTrace 4-byte path, 6 hops×3
+
Flood·02:36 PM·GatineauPeak·YOW
+
+
+
1CE88A03AckAcknowledgment×2
+
Direct·02:35 PM·RobaStick_YOW·YOW
+
+
+
D9F21B7CTextPlain text message×12
+
Flood·02:34 PM·Bells_Corners·YOW
+
+
+
44AE0FD2RequestRequest dest=A3F1 src=9E2B×5
+
Direct·02:33 PM·Hull_Hospital·YOW
+
+
+
E501CAFEAdvertAdvert "VE3XYZ/Ottawa_South"×22
+
Flood·02:32 PM·GatineauPeak·YOW
+
+
+ +
+
WebSocket connected · mqtt1 + mqtt2
+
1,247 packets · 34 observers · 189 nodes
+
+ + diff --git a/colour-palettes/palette-neutral-blue.html b/colour-palettes/palette-neutral-blue.html new file mode 100644 index 0000000..0bc7c52 --- /dev/null +++ b/colour-palettes/palette-neutral-blue.html @@ -0,0 +1,148 @@ + + + + + +MeshCore Tower — Neutral Blue Palette + + + +
+ OPTION M — NEUTRAL BLUE + Clean, familiar dark theme · standard blue primary on pure zinc-black · proven readability +
The "just works" option. Pure zinc grays, blue-500 primary, standard semantic colors. Every developer already knows how to read this. Slightly rounded corners soften it. Differentiation comes from the layout and data density, not the palette.
+
+
+
Primary
+
Secondary
+
Good
+
Danger
+
Base
+
Surface
+
Bright
+
Muted
+
+
TOWER v0.1
LIVE
SCOPE YOW
+
Packets
Nodes
Observers
Channels
Stats
Map
+
All types
Advert
Text
Group
Trace
Ack
All routes
Flood
Direct
+
▲ 47 new packets · click to scroll to top
+
+
+
9E9B7D6AAdvertAdvert "WW7STR/PugetMesh Cougar"×15
+
Flood·02:37 PM·FlightlessDt·KEH
+
+
FlightlessDtKEH02:37:36
SNR10.75
RSSI-98
Prop1.936s
Hops1
PathYOW_KanataFlightlessDt
+
NodeRunnerSEA02:37:38
SNR7.20
RSSI-105
Prop4.122s
Hops2
PathYOW_KanataAE9B (2 candidates)NodeRunner
+
+
+
3FA1C20EGroupGroup text on #ottawa×8
Flood·02:36 PM·Hull_Hospital·YOW
+
B7D44F19TraceTrace 4-byte path, 6 hops×3
Flood·02:36 PM·GatineauPeak·YOW
+
1CE88A03AckAcknowledgment×2
Direct·02:35 PM·RobaStick_YOW·YOW
+
D9F21B7CTextPlain text message×12
Flood·02:34 PM·Bells_Corners·YOW
+
44AE0FD2RequestRequest dest=A3F1 src=9E2B×5
Direct·02:33 PM·Hull_Hospital·YOW
+
E501CAFEAdvertAdvert "VE3XYZ/Ottawa_South"×22
Flood·02:32 PM·GatineauPeak·YOW
+
+
1,247 packets
34 observers
189 nodes
mqtt1 + mqtt2
+ + diff --git a/colour-palettes/palette-phosphor.html b/colour-palettes/palette-phosphor.html new file mode 100644 index 0000000..e6f52b9 --- /dev/null +++ b/colour-palettes/palette-phosphor.html @@ -0,0 +1,603 @@ + + + + + +MeshCore Tower — Green Phosphor Palette + + + + +
+ OPTION C — GREEN PHOSPHOR + Terminal / CRT aesthetic · cold blue-black base · phosphor green primary · cyan accents +
+ +
+
Primary
+
Secondary
+
Danger
+
Warning
+
Base
+
Surface
+
Bright
+
Muted
+
+ +
+
TOWER v0.1
+
YOW
+
+ +
+
Packets
+
Nodes
+
Observers
+
Channels
+
Stats
+
Map
+
+ +
+
All types
+
Advert
+
Text
+
Group
+
Trace
+
Ack
+
+
All routes
+
Flood
+
Direct
+
+ +
▲ 47 new packets · click to scroll to top
+ +
+
+
+ + 9E9B7D6A + Advert + Advert "WW7STR/PugetMesh Cougar" + ×15 +
+
+ Flood + · + 02:37 PM + · + FlightlessDt + · + KEH +
+ +
+
+
+ FlightlessDt + KEH + 02:37:36 +
+
+ SNR 10.75 + RSSI -98 + Prop 1.936s + Hops 1 +
+
+ Path: + YOW_Kanata + + FlightlessDt +
+ +
+ +
+
+ NodeRunner + SEA + 02:37:38 +
+
+ SNR 7.20 + RSSI -105 + Prop 4.122s + Hops 2 +
+
+ Path: + YOW_Kanata + + AE9B (2 candidates) + + NodeRunner +
+ +
+
+
+ +
+
+ + 3FA1C20E + Group + Group text on #ottawa + ×8 +
+
+ Flood + · + 02:36 PM + · + Hull_Hospital + · + YOW +
+
+ +
+
+ + B7D44F19 + Trace + Trace 4-byte path, 6 hops + ×3 +
+
+ Flood + · + 02:36 PM + · + GatineauPeak + · + YOW +
+
+ +
+
+ + 1CE88A03 + Ack + Acknowledgment + ×2 +
+
+ Direct + · + 02:35 PM + · + RobaStick_YOW + · + YOW +
+
+ +
+
+ + D9F21B7C + Text + Plain text message + ×12 +
+
+ Flood + · + 02:34 PM + · + Bells_Corners + · + YOW +
+
+ +
+
+ + 44AE0FD2 + Request + Request dest=A3F1 src=9E2B + ×5 +
+
+ Direct + · + 02:33 PM + · + Hull_Hospital + · + YOW +
+
+ +
+
+ + E501CAFE + Advert + Advert "VE3XYZ/Ottawa_South" + ×22 +
+
+ Flood + · + 02:32 PM + · + GatineauPeak + · + YOW +
+
+
+ +
+
WebSocket connected · mqtt1 + mqtt2
+
1,247 packets · 34 observers · 189 nodes
+
+ + + diff --git a/colour-palettes/palette-radar.html b/colour-palettes/palette-radar.html new file mode 100644 index 0000000..4a56cf2 --- /dev/null +++ b/colour-palettes/palette-radar.html @@ -0,0 +1,365 @@ + + + + + +MeshCore Tower — Radar / ATC Palette + + + + +
+ OPTION G — RADAR / ATC + Air traffic control display aesthetic · high-contrast radar green + sky blue on dark navy +
Designed for long sessions staring at signal data. ATC displays prioritize readability above everything — high contrast, clear type hierarchy, no decorative noise. The green reads as "live data" instantly.
+
+ +
+
Primary
+
Secondary
+
Danger
+
Warning
+
Base
+
Surface
+
Bright
+
Muted
+
+ +
+
TOWER v0.1
+
+
LIVE
+
SCOPE YOW
+
+
+
+
Packets
+
Nodes
+
Observers
+
Channels
+
Stats
+
Map
+
+
+
All types
+
Advert
+
Text
+
Group
+
Trace
+
Ack
+
+
All routes
+
Flood
+
Direct
+
+
▲ 47 new packets · click to scroll to top
+ +
+
+
+ + 9E9B7D6A + Advert + Advert "WW7STR/PugetMesh Cougar" + ×15 +
+
Flood·02:37 PM·FlightlessDt·KEH
+
+
+
FlightlessDtKEH02:37:36
+
+
SNR10.75
+
RSSI-98
+
Prop1.936s
+
Hops1
+
+
+ Path + YOW_KanataFlightlessDt +
+ +
+
+
NodeRunnerSEA02:37:38
+
+
SNR7.20
+
RSSI-105
+
Prop4.122s
+
Hops2
+
+
+ Path + YOW_KanataAE9B (2 candidates)NodeRunner +
+ +
+
+
+ +
+
3FA1C20EGroupGroup text on #ottawa×8
+
Flood·02:36 PM·Hull_Hospital·YOW
+
+
+
B7D44F19TraceTrace 4-byte path, 6 hops×3
+
Flood·02:36 PM·GatineauPeak·YOW
+
+
+
1CE88A03AckAcknowledgment×2
+
Direct·02:35 PM·RobaStick_YOW·YOW
+
+
+
D9F21B7CTextPlain text message×12
+
Flood·02:34 PM·Bells_Corners·YOW
+
+
+
44AE0FD2RequestRequest dest=A3F1 src=9E2B×5
+
Direct·02:33 PM·Hull_Hospital·YOW
+
+
+
E501CAFEAdvertAdvert "VE3XYZ/Ottawa_South"×22
+
Flood·02:32 PM·GatineauPeak·YOW
+
+
+ +
+
+
1,247 packets
+
34 observers
+
189 nodes
+
+
mqtt1 + mqtt2
+
+ + diff --git a/colour-palettes/palette-signal-orange.html b/colour-palettes/palette-signal-orange.html new file mode 100644 index 0000000..46a18fb --- /dev/null +++ b/colour-palettes/palette-signal-orange.html @@ -0,0 +1,222 @@ + + + + + +MeshCore Tower — Signal Orange Palette + + + + +
+ OPTION H — SIGNAL ORANGE + Emergency radio equipment aesthetic · signal orange on deep navy · blue secondary +
Inspired by field radio equipment and emergency comm panels. Orange is high-visibility, distinctive, and immediately signals "radio." Navy background keeps it refined rather than aggressive.
+
+ +
+
Primary
+
Secondary
+
Good
+
Danger
+
Base
+
Surface
+
Bright
+
Muted
+
+ +
+
TOWER v0.1
+
+
LIVE
+
SCOPE YOW
+
+
+
+
Packets
Nodes
Observers
Channels
Stats
Map
+
+
+
All types
Advert
Text
Group
Trace
Ack
+
+
All routes
Flood
Direct
+
+
▲ 47 new packets · click to scroll to top
+ +
+
+
9E9B7D6AAdvertAdvert "WW7STR/PugetMesh Cougar"×15
+
Flood·02:37 PM·FlightlessDt·KEH
+
+
+
FlightlessDtKEH02:37:36
+
+
SNR10.75
+
RSSI-98
+
Prop1.936s
+
Hops1
+
+
PathYOW_KanataFlightlessDt
+ +
+
+
NodeRunnerSEA02:37:38
+
+
SNR7.20
+
RSSI-105
+
Prop4.122s
+
Hops2
+
+
PathYOW_KanataAE9B (2 candidates)NodeRunner
+ +
+
+
+
3FA1C20EGroupGroup text on #ottawa×8
Flood·02:36 PM·Hull_Hospital·YOW
+
B7D44F19TraceTrace 4-byte path, 6 hops×3
Flood·02:36 PM·GatineauPeak·YOW
+
1CE88A03AckAcknowledgment×2
Direct·02:35 PM·RobaStick_YOW·YOW
+
D9F21B7CTextPlain text message×12
Flood·02:34 PM·Bells_Corners·YOW
+
44AE0FD2RequestRequest dest=A3F1 src=9E2B×5
Direct·02:33 PM·Hull_Hospital·YOW
+
E501CAFEAdvertAdvert "VE3XYZ/Ottawa_South"×22
Flood·02:32 PM·GatineauPeak·YOW
+
+ +
+
1,247 packets
34 observers
189 nodes
+
mqtt1 + mqtt2
+
+ + diff --git a/colour-palettes/palette-slate-rose.html b/colour-palettes/palette-slate-rose.html new file mode 100644 index 0000000..9b435c6 --- /dev/null +++ b/colour-palettes/palette-slate-rose.html @@ -0,0 +1,254 @@ + + + + + +MeshCore Tower — Slate / Rose Palette + + + + +
+ OPTION E — SLATE / ROSE + Neutral dark slate base · rose-pink primary · violet secondary · refined but distinctive +
+ +
+
Primary
+
Secondary
+
Good
+
Danger
+
Base
+
Surface
+
Bright
+
Muted
+
+ +
+
TOWER v0.1
+
YOW
+
+
+
Packets
+
Nodes
+
Observers
+
Channels
+
Stats
+
Map
+
+
+
All types
+
Advert
+
Text
+
Group
+
Trace
+
Ack
+
+
All routes
+
Flood
+
Direct
+
+
▲ 47 new packets · click to scroll to top
+ +
+
+
+ + 9E9B7D6A + Advert + Advert "WW7STR/PugetMesh Cougar" + ×15 +
+
Flood·02:37 PM·FlightlessDt·KEH
+
+
+
FlightlessDtKEH02:37:36
+
+ SNR 10.75 + RSSI -98 + Prop 1.936s + Hops 1 +
+
Path:YOW_KanataFlightlessDt
+ +
+
+
NodeRunnerSEA02:37:38
+
+ SNR 7.20 + RSSI -105 + Prop 4.122s + Hops 2 +
+
Path:YOW_KanataAE9B (2 candidates)NodeRunner
+ +
+
+
+ +
+
3FA1C20EGroupGroup text on #ottawa×8
+
Flood·02:36 PM·Hull_Hospital·YOW
+
+
+
B7D44F19TraceTrace 4-byte path, 6 hops×3
+
Flood·02:36 PM·GatineauPeak·YOW
+
+
+
1CE88A03AckAcknowledgment×2
+
Direct·02:35 PM·RobaStick_YOW·YOW
+
+
+
D9F21B7CTextPlain text message×12
+
Flood·02:34 PM·Bells_Corners·YOW
+
+
+
44AE0FD2RequestRequest dest=A3F1 src=9E2B×5
+
Direct·02:33 PM·Hull_Hospital·YOW
+
+
+
E501CAFEAdvertAdvert "VE3XYZ/Ottawa_South"×22
+
Flood·02:32 PM·GatineauPeak·YOW
+
+
+ +
+
WebSocket connected · mqtt1 + mqtt2
+
1,247 packets · 34 observers · 189 nodes
+
+ + diff --git a/colour-palettes/palette-synthwave.html b/colour-palettes/palette-synthwave.html new file mode 100644 index 0000000..c301021 --- /dev/null +++ b/colour-palettes/palette-synthwave.html @@ -0,0 +1,149 @@ + + + + + +MeshCore Tower — Synthwave Palette + + + +
+ OPTION L — SYNTHWAVE + Retro-futuristic aesthetic · hot pink + electric cyan on deep purple · neon glow accents +
Maximum personality. Pink/cyan is the most visually distinctive option and will look like nothing else in the mesh ecosystem. The deep purple base keeps it comfortable for long sessions. Subtle glow effects on the live badge sell the neon feel without going overboard.
+
+
+
Primary
+
Secondary
+
Good
+
Danger
+
Base
+
Surface
+
Bright
+
Muted
+
+
TOWER v0.1
LIVE
SCOPE YOW
+
Packets
Nodes
Observers
Channels
Stats
Map
+
All types
Advert
Text
Group
Trace
Ack
All routes
Flood
Direct
+
▲ 47 new packets · click to scroll to top
+
+
+
9E9B7D6AAdvertAdvert "WW7STR/PugetMesh Cougar"×15
+
Flood·02:37 PM·FlightlessDt·KEH
+
+
FlightlessDtKEH02:37:36
SNR10.75
RSSI-98
Prop1.936s
Hops1
PathYOW_KanataFlightlessDt
+
NodeRunnerSEA02:37:38
SNR7.20
RSSI-105
Prop4.122s
Hops2
PathYOW_KanataAE9B (2 candidates)NodeRunner
+
+
+
3FA1C20EGroupGroup text on #ottawa×8
Flood·02:36 PM·Hull_Hospital·YOW
+
B7D44F19TraceTrace 4-byte path, 6 hops×3
Flood·02:36 PM·GatineauPeak·YOW
+
1CE88A03AckAcknowledgment×2
Direct·02:35 PM·RobaStick_YOW·YOW
+
D9F21B7CTextPlain text message×12
Flood·02:34 PM·Bells_Corners·YOW
+
44AE0FD2RequestRequest dest=A3F1 src=9E2B×5
Direct·02:33 PM·Hull_Hospital·YOW
+
E501CAFEAdvertAdvert "VE3XYZ/Ottawa_South"×22
Flood·02:32 PM·GatineauPeak·YOW
+
+
1,247 packets
34 observers
189 nodes
mqtt1 + mqtt2
+ + diff --git a/colour-palettes/palette-teal.html b/colour-palettes/palette-teal.html new file mode 100644 index 0000000..1d2616d --- /dev/null +++ b/colour-palettes/palette-teal.html @@ -0,0 +1,604 @@ + + + + + +MeshCore Tower — Teal / Copper Palette + + + + +
+ OPTION B — TEAL / COPPER + Radio equipment + PCB trace aesthetic · cool dark base · teal primary · copper accents +
+ +
+
Primary
+
Secondary
+
Danger
+
Warning
+
Base
+
Surface
+
Bright
+
Muted
+
+ +
+
TOWER v0.1
+
YOW
+
+ +
+
Packets
+
Nodes
+
Observers
+
Channels
+
Stats
+
Map
+
+ +
+
All types
+
Advert
+
Text
+
Group
+
Trace
+
Ack
+
+
All routes
+
Flood
+
Direct
+
+ +
▲ 47 new packets · click to scroll to top
+ +
+
+
+ + 9E9B7D6A + Advert + Advert "WW7STR/PugetMesh Cougar" + ×15 +
+
+ Flood + · + 02:37 PM + · + FlightlessDt + · + KEH +
+ +
+
+
+ FlightlessDt + KEH + 02:37:36 +
+
+ SNR 10.75 + RSSI -98 + Prop 1.936s + Hops 1 +
+
+ Path: + YOW_Kanata + + FlightlessDt +
+ +
+ +
+
+ NodeRunner + SEA + 02:37:38 +
+
+ SNR 7.20 + RSSI -105 + Prop 4.122s + Hops 2 +
+
+ Path: + YOW_Kanata + + AE9B (2 candidates) + + NodeRunner +
+ +
+
+
+ +
+
+ + 3FA1C20E + Group + Group text on #ottawa + ×8 +
+
+ Flood + · + 02:36 PM + · + Hull_Hospital + · + YOW +
+
+ +
+
+ + B7D44F19 + Trace + Trace 4-byte path, 6 hops + ×3 +
+
+ Flood + · + 02:36 PM + · + GatineauPeak + · + YOW +
+
+ +
+
+ + 1CE88A03 + Ack + Acknowledgment + ×2 +
+
+ Direct + · + 02:35 PM + · + RobaStick_YOW + · + YOW +
+
+ +
+
+ + D9F21B7C + Text + Plain text message + ×12 +
+
+ Flood + · + 02:34 PM + · + Bells_Corners + · + YOW +
+
+ +
+
+ + 44AE0FD2 + Request + Request dest=A3F1 src=9E2B + ×5 +
+
+ Direct + · + 02:33 PM + · + Hull_Hospital + · + YOW +
+
+ +
+
+ + E501CAFE + Advert + Advert "VE3XYZ/Ottawa_South" + ×22 +
+
+ Flood + · + 02:32 PM + · + GatineauPeak + · + YOW +
+
+
+ +
+
WebSocket connected · mqtt1 + mqtt2
+
1,247 packets · 34 observers · 189 nodes
+
+ + + diff --git a/docker/Caddyfile b/docker/Caddyfile new file mode 100644 index 0000000..6301949 --- /dev/null +++ b/docker/Caddyfile @@ -0,0 +1,12 @@ +:80 { + root * /srv + file_server + try_files {path} /index.html + encode gzip + + header { + X-Content-Type-Options nosniff + X-Frame-Options DENY + Referrer-Policy strict-origin-when-cross-origin + } +} diff --git a/docker/Caddyfile.proxy b/docker/Caddyfile.proxy new file mode 100644 index 0000000..4de8de4 --- /dev/null +++ b/docker/Caddyfile.proxy @@ -0,0 +1,3 @@ +{$DOMAIN} { + reverse_proxy tower-web:80 +} diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..824795c --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,20 @@ +services: + caddy: + image: caddy:2-alpine + ports: + - "80:80" + - "443:443" + environment: + - DOMAIN=${DOMAIN:?DOMAIN environment variable is required} + volumes: + - ./data/Caddy/CaddyFile/Caddyfile.proxy:/etc/caddy/Caddyfile + - ./data/Caddy/data:/data + - ./data/Caddy/config:/config + restart: unless-stopped + + tower-web: + image: ghcr.io/meshcore-tower/tower-web:latest + environment: + - VITE_API_BASE=${VITE_API_BASE:?VITE_API_BASE environment variable is required} + - VITE_WS_URL=${VITE_WS_URL:?VITE_WS_URL environment variable is required} + restart: unless-stopped diff --git a/docker/docker-entrypoint.sh b/docker/docker-entrypoint.sh new file mode 100644 index 0000000..4a12ce9 --- /dev/null +++ b/docker/docker-entrypoint.sh @@ -0,0 +1,12 @@ +#!/bin/sh +set -e + +API_BASE="${VITE_API_BASE:-http://localhost:8080/api/v1}" +WS_URL="${VITE_WS_URL:-ws://localhost:8080/ws}" + +find /srv -name '*.js' -exec sed -i \ + -e "s|__VITE_API_BASE__|${API_BASE}|g" \ + -e "s|__VITE_WS_URL__|${WS_URL}|g" \ + {} + + +exec "$@" diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..ef614d2 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,22 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + globals: globals.browser, + }, + }, +]) diff --git a/index.html b/index.html new file mode 100644 index 0000000..617db2d --- /dev/null +++ b/index.html @@ -0,0 +1,19 @@ + + + + + + + MeshCore Tower + + + + + +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..2f30646 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,4325 @@ +{ + "name": "tower-web", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "tower-web", + "version": "0.0.0", + "dependencies": { + "@tanstack/react-query": "^5.100.11", + "@tanstack/react-virtual": "^3.13.25", + "react": "^19.2.6", + "react-dom": "^19.2.6", + "react-router-dom": "^7.15.1" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@tailwindcss/vite": "^4.3.0", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@types/node": "^24.12.3", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^10.3.0", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.6.0", + "jsdom": "^29.1.1", + "tailwindcss": "^4.3.0", + "typescript": "~6.0.2", + "typescript-eslint": "^8.59.2", + "vite": "^8.0.12", + "vitest": "^4.1.7" + } + }, + "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", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", + "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.1.tgz", + "integrity": "sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.4.tgz", + "integrity": "sha512-wgsqt92b7C7tQhIdPNxj0n9zuUbQlvAuI1exyzeNrOKOi62SD7ren8zqszmpVREjAOqg8cD2FqYhQfAuKjk4sw==", + "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", + "peer": true, + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz", + "integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.132.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz", + "integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz", + "integrity": "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.2.tgz", + "integrity": "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz", + "integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.2.tgz", + "integrity": "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.2.tgz", + "integrity": "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.2.tgz", + "integrity": "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.2.tgz", + "integrity": "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.2.tgz", + "integrity": "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.2.tgz", + "integrity": "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.2.tgz", + "integrity": "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.2.tgz", + "integrity": "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.2.tgz", + "integrity": "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.2.tgz", + "integrity": "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz", + "integrity": "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.2.tgz", + "integrity": "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@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/node": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", + "integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.21.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz", + "integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-x64": "4.3.0", + "@tailwindcss/oxide-freebsd-x64": "4.3.0", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-x64-musl": "4.3.0", + "@tailwindcss/oxide-wasm32-wasi": "4.3.0", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz", + "integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz", + "integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz", + "integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz", + "integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz", + "integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz", + "integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz", + "integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz", + "integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz", + "integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz", + "integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", + "integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz", + "integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.0.tgz", + "integrity": "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.0", + "@tailwindcss/oxide": "4.3.0", + "tailwindcss": "4.3.0" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.100.14", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.14.tgz", + "integrity": "sha512-5X41dGpxgeaHISCRW2oYwcSycZeULZzAunaudXT9ov1KOTj9xwt0CH6hbwqP1/z74ZWF7rYFnDpyYH07XFcZew==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.100.14", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.14.tgz", + "integrity": "sha512-oOr6aRdSFEwWhzxEkD/9ZcItM3+LjBSkeVmadWKwUssAHTsqd/7bOjWrX4AbvEkoEhgAxzN0Xk6H/aYzXiYBAw==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.100.14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tanstack/react-virtual": { + "version": "3.13.25", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.25.tgz", + "integrity": "sha512-bmNoqMu6gcAW9JGrKVB0Q1tN1i5RONZF8r1fW0bbE4Oyf3DwEGnzzQJ2OW+Ozg1P4s8PyugkHg2ULZoFQN+cqw==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.15.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.15.0.tgz", + "integrity": "sha512-0AwPGx0I8QxPYjAxShT/+z+ZOe9u8mW5rsXvivCTjRfRmz9a43+3mRyi4wwlyoUqOC56q/jatKa0Bh9M99BEHQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/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/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/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/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.12.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz", + "integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.15", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", + "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", + "dev": true, + "license": "MIT", + "peer": true, + "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", + "peer": true, + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.4.tgz", + "integrity": "sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.59.4", + "@typescript-eslint/type-utils": "8.59.4", + "@typescript-eslint/utils": "8.59.4", + "@typescript-eslint/visitor-keys": "8.59.4", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.59.4", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.4.tgz", + "integrity": "sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@typescript-eslint/scope-manager": "8.59.4", + "@typescript-eslint/types": "8.59.4", + "@typescript-eslint/typescript-estree": "8.59.4", + "@typescript-eslint/visitor-keys": "8.59.4", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.4.tgz", + "integrity": "sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.59.4", + "@typescript-eslint/types": "^8.59.4", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.4.tgz", + "integrity": "sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.4", + "@typescript-eslint/visitor-keys": "8.59.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.4.tgz", + "integrity": "sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.4.tgz", + "integrity": "sha512-uonTuPAAKr9XaBGqJ3LjYTh72zy5DyGesljO9gtmk/eFW0W1fRHjnwVYKB35Lm8d5Q5CluEW3gPHjTvZTmgrfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.4", + "@typescript-eslint/typescript-estree": "8.59.4", + "@typescript-eslint/utils": "8.59.4", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.4.tgz", + "integrity": "sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.4.tgz", + "integrity": "sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.59.4", + "@typescript-eslint/tsconfig-utils": "8.59.4", + "@typescript-eslint/types": "8.59.4", + "@typescript-eslint/visitor-keys": "8.59.4", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.4.tgz", + "integrity": "sha512-cYXeNAUsG4lJo5dbc1FcKm+JwIWrj1/UpTORsC6tGMjEZ81DYcvIr9/ueikhMa/Y/gDQYGp+YX9/xQrXje5BJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.59.4", + "@typescript-eslint/types": "8.59.4", + "@typescript-eslint/typescript-estree": "8.59.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.4.tgz", + "integrity": "sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.4", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz", + "integrity": "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz", + "integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz", + "integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.7", + "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.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz", + "integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz", + "integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.7", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz", + "integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.7", + "@vitest/utils": "4.1.7", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz", + "integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz", + "integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.7", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "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/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.32", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz", + "integrity": "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==", + "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/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "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", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "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.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/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/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-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==", + "dev": true, + "license": "MIT" + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.361", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.361.tgz", + "integrity": "sha512-Q6Hts7N9FnJc5LeGRINFvLhCI9xZmNtTDe5ZbcVezQz7cU4a8Aua3GH1b8J2XY8Al9PF+OCwYqhgsOOheMdvkA==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.0.tgz", + "integrity": "sha512-xYcDWrpELkFzz9SpZ3PlI6Eu6eD93Yf0WLDRxikGhWJ3MAir2SNZTIVCVZqZ/NUyx8AdMc2gT9C0gPiw18kG+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.4.0.tgz", + "integrity": "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.2.tgz", + "integrity": "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": "^9 || ^10" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/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/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.6.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz", + "integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "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/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/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.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz", + "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==", + "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/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/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": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/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/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.46", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz", + "integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "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/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/react": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", + "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", + "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.6" + } + }, + "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/react-router": { + "version": "7.15.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.1.tgz", + "integrity": "sha512-R8rl9HhgikFYoPJymnUtPXWbnDb3oget6lQnfIoupbt61aT9aOhRkDsY2XRhZRyX1Z/8a5sL74fXmFNm3NRK5A==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.15.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.15.1.tgz", + "integrity": "sha512-AzF62gjY6U9rkMq4RfP/r2EVtQ7DMfNMjyOp/flLTCrtRylLiK4wT4pSq6O8rOXZ2eXdZYJPEYe+ifomiv+Igg==", + "license": "MIT", + "dependencies": { + "react-router": "7.15.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "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/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/rolldown": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz", + "integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.132.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.2", + "@rolldown/binding-darwin-arm64": "1.0.2", + "@rolldown/binding-darwin-x64": "1.0.2", + "@rolldown/binding-freebsd-x64": "1.0.2", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.2", + "@rolldown/binding-linux-arm64-gnu": "1.0.2", + "@rolldown/binding-linux-arm64-musl": "1.0.2", + "@rolldown/binding-linux-ppc64-gnu": "1.0.2", + "@rolldown/binding-linux-s390x-gnu": "1.0.2", + "@rolldown/binding-linux-x64-gnu": "1.0.2", + "@rolldown/binding-linux-x64-musl": "1.0.2", + "@rolldown/binding-openharmony-arm64": "1.0.2", + "@rolldown/binding-wasm32-wasi": "1.0.2", + "@rolldown/binding-win32-arm64-msvc": "1.0.2", + "@rolldown/binding-win32-x64-msvc": "1.0.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/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/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/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.0", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", + "integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.2.tgz", + "integrity": "sha512-M/Q0B2cp4K7kynaT/vnED1j8TlLY+Pp7C6Wl2bl/7u/F0mUVwdyOpwomQb8JpYLitHUssAJRmLZdMCGsrx7i+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "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.0.32", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.32.tgz", + "integrity": "sha512-5eDV0tK2NhLAAqBeXDAQ36+EwuStd1HbsSOnGsp+JbExITnExcALLL5M1kTH8gjDYN5QvwmUWimE3GoMZ2A7xQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.32" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.32", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.32.tgz", + "integrity": "sha512-GbSw6chU2HsHIB/NAyBJYxXQKpcqhtgY6bvWi90OarpbuMia+Z4Gw5BYiW4hJo1DzQ/6k9coba7FU/ijcgVLLw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.4.tgz", + "integrity": "sha512-Rw6+44QNFaXtgHSjPy+Kw8hrJniMYzR85E9yLmOLcfZ91/rz+JXQbDTCmc6ccxMPY6K6PgAq26f0JCBfR7LIPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.59.4", + "@typescript-eslint/parser": "8.59.4", + "@typescript-eslint/typescript-estree": "8.59.4", + "@typescript-eslint/utils": "8.59.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici": { + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/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/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "8.0.14", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz", + "integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.2", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz", + "integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.7", + "@vitest/mocker": "4.1.7", + "@vitest/pretty-format": "4.1.7", + "@vitest/runner": "4.1.7", + "@vitest/snapshot": "4.1.7", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", + "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.7", + "@vitest/browser-preview": "4.1.7", + "@vitest/browser-webdriverio": "4.1.7", + "@vitest/coverage-istanbul": "4.1.7", + "@vitest/coverage-v8": "4.1.7", + "@vitest/ui": "4.1.7", + "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/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/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/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/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..a546f8d --- /dev/null +++ b/package.json @@ -0,0 +1,39 @@ +{ + "name": "tower-web", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@tanstack/react-query": "^5.100.11", + "@tanstack/react-virtual": "^3.13.25", + "react": "^19.2.6", + "react-dom": "^19.2.6", + "react-router-dom": "^7.15.1" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@tailwindcss/vite": "^4.3.0", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@types/node": "^24.12.3", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^10.3.0", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.6.0", + "jsdom": "^29.1.1", + "tailwindcss": "^4.3.0", + "typescript": "~6.0.2", + "typescript-eslint": "^8.59.2", + "vite": "^8.0.12", + "vitest": "^4.1.7" + } +} diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/icons.svg b/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/themes.json b/public/themes.json new file mode 100644 index 0000000..29a089c --- /dev/null +++ b/public/themes.json @@ -0,0 +1,338 @@ +[ + { + "id": "neutral-blue", + "name": "Neutral Blue", + "vars": { + "--palette-bg-base": "#09090B", + "--palette-bg-surface": "#111114", + "--palette-bg-raised": "#1A1A1F", + "--palette-border": "#27272A", + "--palette-border-subtle": "#1E1E22", + "--palette-primary": "#3B82F6", + "--palette-primary-dim": "#1D4ED8", + "--palette-secondary": "#A78BFA", + "--palette-green": "#22C55E", + "--palette-danger": "#EF4444", + "--palette-warn": "#EAB308", + "--palette-text-bright": "#FAFAFA", + "--palette-text-normal": "#A1A1AA", + "--palette-text-muted": "#73737B", + "--palette-text-dim": "#5F5F65" + } + }, + { + "id": "arctic", + "name": "Arctic", + "vars": { + "--palette-bg-base": "#0A0E18", + "--palette-bg-surface": "#101624", + "--palette-bg-raised": "#182030", + "--palette-border": "#1E2C44", + "--palette-border-subtle": "#152438", + "--palette-primary": "#38BDF8", + "--palette-primary-dim": "#0369A1", + "--palette-secondary": "#C084FC", + "--palette-green": "#34D399", + "--palette-danger": "#FB7185", + "--palette-warn": "#FCD34D", + "--palette-text-bright": "#F0F4FA", + "--palette-text-normal": "#A4B4CC", + "--palette-text-muted": "#667790", + "--palette-text-dim": "#56647A" + } + }, + { + "id": "amber", + "name": "Amber", + "vars": { + "--palette-bg-base": "#0C0A09", + "--palette-bg-surface": "#1C1917", + "--palette-bg-raised": "#292524", + "--palette-border": "#292524", + "--palette-border-subtle": "#1C1917", + "--palette-primary": "#F59E0B", + "--palette-primary-dim": "#B45309", + "--palette-secondary": "#4ADE80", + "--palette-green": "#4ADE80", + "--palette-danger": "#EF4444", + "--palette-warn": "#FBBF24", + "--palette-text-bright": "#FAFAF9", + "--palette-text-normal": "#D6D3D1", + "--palette-text-muted": "#A8A29E", + "--palette-text-dim": "#78716C" + } + }, + { + "id": "charcoal-white", + "name": "Charcoal White", + "vars": { + "--palette-bg-base": "#161616", + "--palette-bg-surface": "#1E1E1E", + "--palette-bg-raised": "#282828", + "--palette-border": "#333333", + "--palette-border-subtle": "#252525", + "--palette-primary": "#FFFFFF", + "--palette-primary-dim": "#999999", + "--palette-secondary": "#3B82F6", + "--palette-green": "#22C55E", + "--palette-danger": "#EF4444", + "--palette-warn": "#EAB308", + "--palette-text-bright": "#FFFFFF", + "--palette-text-normal": "#B0B0B0", + "--palette-text-muted": "#7C7C7C", + "--palette-text-dim": "#696969" + } + }, + { + "id": "command", + "name": "Command", + "vars": { + "--palette-bg-base": "#0C0A10", + "--palette-bg-surface": "#14111A", + "--palette-bg-raised": "#1E1A26", + "--palette-border": "#2A2434", + "--palette-border-subtle": "#1E1828", + "--palette-primary": "#E85D75", + "--palette-primary-dim": "#8B2E42", + "--palette-secondary": "#8B9FE8", + "--palette-green": "#5AD8A6", + "--palette-danger": "#FF6B6B", + "--palette-warn": "#F0C674", + "--palette-text-bright": "#F0ECF4", + "--palette-text-normal": "#B4AACC", + "--palette-text-muted": "#796F91", + "--palette-text-dim": "#645D75" + } + }, + { + "id": "cyberpunk", + "name": "Cyberpunk", + "vars": { + "--palette-bg-base": "#0C0A14", + "--palette-bg-surface": "#13101E", + "--palette-bg-raised": "#1A1628", + "--palette-border": "#2A2440", + "--palette-border-subtle": "#1E1A30", + "--palette-primary": "#00F0FF", + "--palette-primary-dim": "#0088A0", + "--palette-secondary": "#FF2E97", + "--palette-green": "#39FF14", + "--palette-danger": "#FF3030", + "--palette-warn": "#FFE744", + "--palette-text-bright": "#EAE6F5", + "--palette-text-normal": "#9E96B8", + "--palette-text-muted": "#766F93", + "--palette-text-dim": "#625D79" + } + }, + { + "id": "earth", + "name": "Earth", + "vars": { + "--palette-bg-base": "#110E0A", + "--palette-bg-surface": "#1A1610", + "--palette-bg-raised": "#26211A", + "--palette-border": "#302A20", + "--palette-border-subtle": "#201C14", + "--palette-primary": "#D4915C", + "--palette-primary-dim": "#8B5A30", + "--palette-secondary": "#7DD3A8", + "--palette-green": "#7DD3A8", + "--palette-danger": "#E57373", + "--palette-warn": "#F0C674", + "--palette-text-bright": "#F0E8DA", + "--palette-text-normal": "#C4B8A4", + "--palette-text-muted": "#8A7E6C", + "--palette-text-dim": "#6B6253" + } + }, + { + "id": "electric-lime", + "name": "Electric Lime", + "vars": { + "--palette-bg-base": "#0A0C08", + "--palette-bg-surface": "#121410", + "--palette-bg-raised": "#1C1E18", + "--palette-border": "#282C22", + "--palette-border-subtle": "#1E2218", + "--palette-primary": "#B8E636", + "--palette-primary-dim": "#6A8A14", + "--palette-secondary": "#36C4E6", + "--palette-green": "#4ADE80", + "--palette-danger": "#FF6B6B", + "--palette-warn": "#FFD166", + "--palette-text-bright": "#EEF0E8", + "--palette-text-normal": "#B0B8A0", + "--palette-text-muted": "#6F7861", + "--palette-text-dim": "#5D6454" + } + }, + { + "id": "indigo-clean", + "name": "Indigo Clean", + "vars": { + "--palette-bg-base": "#0B0B14", + "--palette-bg-surface": "#12121C", + "--palette-bg-raised": "#1A1A28", + "--palette-border": "#252536", + "--palette-border-subtle": "#1C1C2A", + "--palette-primary": "#6366F1", + "--palette-primary-dim": "#4338CA", + "--palette-secondary": "#06B6D4", + "--palette-green": "#10B981", + "--palette-danger": "#F43F5E", + "--palette-warn": "#F59E0B", + "--palette-text-bright": "#F8F8FC", + "--palette-text-normal": "#A0A0B8", + "--palette-text-muted": "#717194", + "--palette-text-dim": "#606076" + } + }, + { + "id": "midnight-gold", + "name": "Midnight Gold", + "vars": { + "--palette-bg-base": "#07081A", + "--palette-bg-surface": "#0E1030", + "--palette-bg-raised": "#181C48", + "--palette-border": "#1E2254", + "--palette-border-subtle": "#141840", + "--palette-primary": "#E2B340", + "--palette-primary-dim": "#9A7520", + "--palette-secondary": "#60A5FA", + "--palette-green": "#4ADE80", + "--palette-danger": "#F87171", + "--palette-warn": "#FBBF24", + "--palette-text-bright": "#F0ECE0", + "--palette-text-normal": "#B8B2A0", + "--palette-text-muted": "#7A7566", + "--palette-text-dim": "#615E81" + } + }, + { + "id": "phosphor", + "name": "Phosphor", + "vars": { + "--palette-bg-base": "#030712", + "--palette-bg-surface": "#0A1628", + "--palette-bg-raised": "#14243D", + "--palette-border": "#14243D", + "--palette-border-subtle": "#0E1C32", + "--palette-primary": "#4ADE80", + "--palette-primary-dim": "#166534", + "--palette-secondary": "#22D3EE", + "--palette-green": "#4ADE80", + "--palette-danger": "#F87171", + "--palette-warn": "#FDE047", + "--palette-text-bright": "#DCFCE7", + "--palette-text-normal": "#A7D8B4", + "--palette-text-muted": "#6A7C64", + "--palette-text-dim": "#506950" + } + }, + { + "id": "radar", + "name": "Radar", + "vars": { + "--palette-bg-base": "#0B1118", + "--palette-bg-surface": "#111921", + "--palette-bg-raised": "#1A242E", + "--palette-border": "#243040", + "--palette-border-subtle": "#182430", + "--palette-primary": "#3EE08F", + "--palette-primary-dim": "#1A7A48", + "--palette-secondary": "#5CC8E4", + "--palette-green": "#3EE08F", + "--palette-danger": "#FF6B6B", + "--palette-warn": "#FFD166", + "--palette-text-bright": "#EAF0F4", + "--palette-text-normal": "#B0BEC5", + "--palette-text-muted": "#607D8B", + "--palette-text-dim": "#536674" + } + }, + { + "id": "signal-orange", + "name": "Signal Orange", + "vars": { + "--palette-bg-base": "#08101C", + "--palette-bg-surface": "#0E1828", + "--palette-bg-raised": "#162236", + "--palette-border": "#1E3050", + "--palette-border-subtle": "#142640", + "--palette-primary": "#FF8C42", + "--palette-primary-dim": "#A85520", + "--palette-secondary": "#64B5F6", + "--palette-green": "#66DE93", + "--palette-danger": "#FF6B6B", + "--palette-warn": "#FFE066", + "--palette-text-bright": "#ECF0F8", + "--palette-text-normal": "#A8B8CC", + "--palette-text-muted": "#647897", + "--palette-text-dim": "#56667A" + } + }, + { + "id": "slate-rose", + "name": "Slate Rose", + "vars": { + "--palette-bg-base": "#0F0F14", + "--palette-bg-surface": "#18181F", + "--palette-bg-raised": "#24242E", + "--palette-border": "#2A2A36", + "--palette-border-subtle": "#1E1E28", + "--palette-primary": "#F472B6", + "--palette-primary-dim": "#9D174D", + "--palette-secondary": "#A78BFA", + "--palette-green": "#4ADE80", + "--palette-danger": "#F87171", + "--palette-warn": "#FBBF24", + "--palette-text-bright": "#F0EEF4", + "--palette-text-normal": "#B8B4C4", + "--palette-text-muted": "#79758A", + "--palette-text-dim": "#666275" + } + }, + { + "id": "synthwave", + "name": "Synthwave", + "vars": { + "--palette-bg-base": "#0E0816", + "--palette-bg-surface": "#160E22", + "--palette-bg-raised": "#201630", + "--palette-border": "#2C2040", + "--palette-border-subtle": "#1C1430", + "--palette-primary": "#FF6EC7", + "--palette-primary-dim": "#A83878", + "--palette-secondary": "#00E5FF", + "--palette-green": "#5AE8B6", + "--palette-danger": "#FF5C5C", + "--palette-warn": "#FFD54F", + "--palette-text-bright": "#F4ECFF", + "--palette-text-normal": "#B8A8D8", + "--palette-text-muted": "#7D6B9E", + "--palette-text-dim": "#675A7D" + } + }, + { + "id": "teal", + "name": "Teal", + "vars": { + "--palette-bg-base": "#0A0F0F", + "--palette-bg-surface": "#111B1B", + "--palette-bg-raised": "#1A2727", + "--palette-border": "#1A2727", + "--palette-border-subtle": "#142020", + "--palette-primary": "#14B8A6", + "--palette-primary-dim": "#0F766E", + "--palette-secondary": "#E87A41", + "--palette-green": "#4ADE80", + "--palette-danger": "#EF4444", + "--palette-warn": "#FBBF24", + "--palette-text-bright": "#E2F0F0", + "--palette-text-normal": "#B0CECE", + "--palette-text-muted": "#6B8A8A", + "--palette-text-dim": "#4D6A6A" + } + } +] diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..10b561e --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,83 @@ +import React, { useState, useEffect } from "react"; +import { BrowserRouter, useSearchParams } from "react-router-dom"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { RegionProvider, useRegion } from "./hooks/useRegion"; +import { ThemeProvider } from "./hooks/useTheme"; +import { AppShell } from "./components/AppShell"; +import { PacketList } from "./features/packets/PacketList"; +import { NodeTable } from "./features/nodes/NodeTable"; +import { ObserverTable } from "./features/observers/ObserverTable"; +import { ChannelList } from "./features/channels/ChannelList"; +import { StatsOverview } from "./features/stats/StatsOverview"; +import { MapView } from "./features/map/MapView"; +import { WsManager } from "./api/ws-manager"; +import { WS_URL } from "./lib/constants"; + +// global singletons + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: 2, + refetchOnWindowFocus: false, + }, + }, +}); + +const wsManager = new WsManager(WS_URL); + +// null-render component -- easiest way to sync region changes into the WS manager + +function RegionWatcher({ wsManager: mgr }: { wsManager: WsManager }) { + const region = useRegion(); + + useEffect(() => { + mgr.updateSubscription({ iatas: region === "*" ? undefined : [region], events: ["packetObservation"] }); + }, [mgr, region]); + + return null; +} + +// tab state and region init + +function AppInner() { + const [activeTab, setActiveTab] = useState("Packets"); + const [searchParams] = useSearchParams(); + const initialRegion = searchParams.get("region") ?? localStorage.getItem("tower-region") ?? "*"; + + useEffect(() => { + wsManager.connect({ iatas: initialRegion === "*" ? undefined : [initialRegion], events: ["packetObservation"] }); + return () => wsManager.disconnect(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const tabContent: Record = { + Packets: , + Nodes: , + Observers: , + Channels: , + Stats: , + Map: , + }; + + return ( + + + + {tabContent[activeTab]} + + + ); +} + +export function App() { + return ( + + + + + + + + ); +} diff --git a/src/api/client.ts b/src/api/client.ts new file mode 100644 index 0000000..2165d37 --- /dev/null +++ b/src/api/client.ts @@ -0,0 +1,60 @@ +import { API_BASE, DEFAULT_PAGE_SIZE } from "../lib/constants"; +import type { CursorPage, PacketSummary, PacketDetail, IataCode } from "../types/api"; + +// typed fetch wrapper with query params + +class ApiError extends Error { + status: number; + code: string; + + constructor(status: number, code: string, message: string) { + super(message); + this.name = "ApiError"; + this.status = status; + this.code = code; + } +} + +async function request(path: string, params?: Record): Promise { + const url = new URL(`${API_BASE}${path}`); + if (params) { + for (const [key, value] of Object.entries(params)) { + if (value !== undefined) { + url.searchParams.set(key, String(value)); + } + } + } + + const res = await fetch(url.toString()); + + if (!res.ok) { + const body = await res.json().catch(() => ({ error: { code: "unknown", message: res.statusText } })); + throw new ApiError(res.status, body.error?.code ?? "unknown", body.error?.message ?? res.statusText); + } + + return res.json(); +} + +// endpoint functions + +export function getPackets( + iata: string, + params?: { cursor?: string; limit?: number; afterId?: number }, +): Promise> { + return request("/packets", { + iata: iata === "*" ? undefined : iata, + cursor: params?.cursor, + limit: params?.limit ?? DEFAULT_PAGE_SIZE, + afterId: params?.afterId, + }); +} + +export function getPacketDetail(packetHash: string): Promise { + return request(`/packets/${packetHash}`); +} + +export function getIatas(): Promise { + return request("/iatas"); +} + +export { ApiError }; diff --git a/src/api/ws-manager.ts b/src/api/ws-manager.ts new file mode 100644 index 0000000..01cf9e2 --- /dev/null +++ b/src/api/ws-manager.ts @@ -0,0 +1,223 @@ +import type { SubscriptionFilter, WsServerMessage, WsPacketObservation, WsLagged } from "../types/ws"; +import { + WS_PING_INTERVAL_MS, + WS_RECONNECT_BASE_MS, + WS_RECONNECT_MAX_MS, + WS_RECONNECT_JITTER, +} from "../lib/constants"; + +// handler types and status + +export type WsStatus = "connected" | "connecting" | "disconnected" | "error"; + +type PacketHandler = (data: WsPacketObservation["data"]) => void; +type LaggedHandler = (data: WsLagged) => void; +type StatusHandler = (status: WsStatus) => void; + +export class WsManager { + private ws: WebSocket | null = null; + private url: string; + private filter: SubscriptionFilter | null = null; + private subscriptionId: string | null = null; + private status: WsStatus = "disconnected"; + private reconnectAttempt = 0; + private reconnectTimer: ReturnType | null = null; + private pingTimer: ReturnType | null = null; + private msgCounter = 0; + private lastEventTimestamp: number = Date.now(); + + private packetHandlers: PacketHandler[] = []; + private laggedHandlers: LaggedHandler[] = []; + private statusHandlers: StatusHandler[] = []; + + constructor(url: string) { + this.url = url; + } + + getStatus(): WsStatus { + return this.status; + } + + getLastEventTimestamp(): number { + return this.lastEventTimestamp; + } + + onPacketObservation(handler: PacketHandler): () => void { + this.packetHandlers.push(handler); + return () => { + this.packetHandlers = this.packetHandlers.filter((h) => h !== handler); + }; + } + + onLagged(handler: LaggedHandler): () => void { + this.laggedHandlers.push(handler); + return () => { + this.laggedHandlers = this.laggedHandlers.filter((h) => h !== handler); + }; + } + + onStatusChange(handler: StatusHandler): () => void { + this.statusHandlers.push(handler); + return () => { + this.statusHandlers = this.statusHandlers.filter((h) => h !== handler); + }; + } + + // connection lifecycle + + connect(filter: SubscriptionFilter): void { + this.filter = filter; + this.doConnect(); + } + + updateSubscription(filter: SubscriptionFilter): void { + this.filter = filter; + + if (this.ws?.readyState !== WebSocket.OPEN) return; + + if (this.subscriptionId) { + this.send({ v: 1, type: "unsubscribe", id: `unsub-${this.nextId()}`, subscriptionId: this.subscriptionId }); + this.subscriptionId = null; + } + + this.sendSubscribe(); + } + + disconnect(): void { + this.clearTimers(); + this.ws?.close(); + this.ws = null; + this.setStatus("disconnected"); + } + + // exponential backoff w/ jitter to avoid thundering herd on reconnect + + private doConnect(): void { + this.clearTimers(); + this.setStatus("connecting"); + + this.ws = new WebSocket(this.url); + + this.ws.onopen = () => { + this.reconnectAttempt = 0; + }; + + this.ws.onmessage = (e: MessageEvent) => { + let msg: WsServerMessage; + try { + msg = JSON.parse(e.data as string) as WsServerMessage; + } catch { + // FIXME: should probably log parse failures somewhere + return; + } + this.handleMessage(msg); + }; + + this.ws.onclose = (e: CloseEvent) => { + this.clearTimers(); + if (e.code === 1000) { + this.setStatus("disconnected"); + return; + } + this.scheduleReconnect(); + }; + + this.ws.onerror = () => { + this.setStatus("error"); + }; + } + + private handleMessage(msg: WsServerMessage): void { + switch (msg.type) { + case "hello": + this.setStatus("connected"); + this.startPing(); + this.sendSubscribe(); + break; + + case "subscribed": + this.subscriptionId = msg.subscriptionId; + break; + + case "pong": + break; + + case "event": + this.lastEventTimestamp = Date.now(); + if (msg.event === "packetObservation") { + for (const handler of this.packetHandlers) { + handler(msg.data); + } + } + break; + + case "lagged": + for (const handler of this.laggedHandlers) { + handler(msg); + } + break; + + case "error": + break; + } + } + + private sendSubscribe(): void { + if (!this.filter) return; + this.send({ + v: 1, + type: "subscribe", + id: `sub-${this.nextId()}`, + scope: this.filter, + }); + } + + private startPing(): void { + this.pingTimer = setInterval(() => { + this.send({ v: 1, type: "ping", id: `p-${this.nextId()}` }); + }, WS_PING_INTERVAL_MS); + } + + private scheduleReconnect(): void { + this.setStatus("connecting"); + const base = Math.min(WS_RECONNECT_BASE_MS * 2 ** this.reconnectAttempt, WS_RECONNECT_MAX_MS); + const jitter = base * WS_RECONNECT_JITTER * (Math.random() * 2 - 1); + const delay = Math.max(base + jitter, 100); + this.reconnectAttempt++; + + this.reconnectTimer = setTimeout(() => { + this.doConnect(); + }, delay); + } + + // internal helpers + + private send(data: unknown): void { + if (this.ws?.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify(data)); + } + } + + private nextId(): number { + return ++this.msgCounter; + } + + private setStatus(status: WsStatus): void { + if (this.status === status) return; + this.status = status; + for (const handler of this.statusHandlers) { + handler(status); + } + } + + private clearTimers(): void { + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + if (this.pingTimer) { + clearInterval(this.pingTimer); + this.pingTimer = null; + } + } +} diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx new file mode 100644 index 0000000..68c4d48 --- /dev/null +++ b/src/components/AppShell.tsx @@ -0,0 +1,240 @@ +import { type ReactNode, useState, useEffect } from "react"; +import { ErrorBoundary } from "./ErrorBoundary"; +import { useQuery } from "@tanstack/react-query"; +import { useRegion, useSetRegion } from "../hooks/useRegion"; +import { useWsStatus } from "../hooks/useWsStatus"; +import { useTheme } from "../hooks/useTheme"; +import { Dropdown } from "./Dropdown"; +import { getIatas } from "../api/client"; +import type { WsManager } from "../api/ws-manager"; + +const TABS = ["Packets", "Nodes", "Observers", "Channels", "Stats", "Map"] as const; + +// header widgets: WS status, region picker, theme picker + +function LiveBadge({ wsManager }: { wsManager: WsManager }) { + const { status } = useWsStatus(wsManager); + const [staleStr, setStaleStr] = useState(""); + + useEffect(() => { + if (status !== "connecting") return; + function update() { + const staleSec = Math.floor((Date.now() - wsManager.getLastEventTimestamp()) / 1000); + setStaleStr(staleSec > 60 ? `${Math.floor(staleSec / 60)}m` : `${staleSec}s`); + } + update(); + const id = setInterval(update, 1000); + return () => clearInterval(id); + }, [status, wsManager]); + + if (status === "connected") { + return ( +
+ + LIVE +
+ ); + } + + if (status === "connecting") { + return ( +
+ STALE {staleStr} +
+ ); + } + + return ( +
+ OFFLINE +
+ ); +} + +function RegionSelector() { + const region = useRegion(); + const setRegion = useSetRegion(); + + const { data: iatas } = useQuery({ + queryKey: ["iatas"], + queryFn: getIatas, + staleTime: 60_000, + }); + + const current = iatas?.find((i) => i.iata === region); + + return ( + ( + + )} + > + {(close) => iatas ? ( + <> + + {iatas.map((i) => ( + + ))} + + ) : ( + + )} + + ); +} + +function ThemePicker() { + const { themeId, themes, setThemeId } = useTheme(); + const current = themes.find((t) => t.id === themeId); + + return ( + ( + + )} + > + {(close) => ( + <> + {themes.map((t) => ( + + ))} + + )} + + ); +} + +// top-level layout: header, tabs, content, footer + +interface AppShellProps { + activeTab: string; + onTabChange: (tab: string) => void; + wsManager: WsManager; + children: ReactNode; +} + +export function AppShell({ activeTab, onTabChange, wsManager, children }: AppShellProps) { + return ( +
+
+ + TOWER v0.1 + +
+ + + +
+
+ + + +
+ {children} +
+ +
+ MeshCore Tower v0.1 +
+
+ ); +} diff --git a/src/components/Badge.tsx b/src/components/Badge.tsx new file mode 100644 index 0000000..1505510 --- /dev/null +++ b/src/components/Badge.tsx @@ -0,0 +1,12 @@ +import type { ReactNode } from "react"; +import { VARIANT_CLASSES, type BadgeVariant } from "./badge-utils"; + +export function Badge({ variant, children }: { variant: BadgeVariant; children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/src/components/Dropdown.tsx b/src/components/Dropdown.tsx new file mode 100644 index 0000000..23fc2d5 --- /dev/null +++ b/src/components/Dropdown.tsx @@ -0,0 +1,26 @@ +import { useState, useRef, useCallback, type ReactNode } from "react"; +import { useClickOutside } from "../hooks/useClickOutside"; + +export function Dropdown({ renderTrigger, align = "right", width = "w-48", children }: { + renderTrigger: (props: { open: boolean; toggle: () => void }) => ReactNode; + align?: "left" | "right"; + width?: string; + children: (close: () => void) => ReactNode; +}) { + const [open, setOpen] = useState(false); + const ref = useRef(null); + const close = useCallback(() => setOpen(false), []); + const toggle = useCallback(() => setOpen((v) => !v), []); + useClickOutside(ref, open, close); + + return ( +
+ {renderTrigger({ open, toggle })} + {open && ( +
+ {children(close)} +
+ )} +
+ ); +} diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..87f053a --- /dev/null +++ b/src/components/ErrorBoundary.tsx @@ -0,0 +1,37 @@ +import { Component, type ReactNode } from "react"; + +interface Props { + children: ReactNode; + fallback?: ReactNode; +} + +interface State { + error: Error | null; +} + +export class ErrorBoundary extends Component { + state: State = { error: null }; + + static getDerivedStateFromError(error: Error): State { + return { error }; + } + + render() { + if (this.state.error) { + if (this.props.fallback) return this.props.fallback; + return ( +
+

Something went wrong rendering this view.

+ +
+ ); + } + return this.props.children; + } +} diff --git a/src/components/FilterBar.tsx b/src/components/FilterBar.tsx new file mode 100644 index 0000000..4faa474 --- /dev/null +++ b/src/components/FilterBar.tsx @@ -0,0 +1,96 @@ +import { MultiSelectDropdown } from "./MultiSelectDropdown"; +import { SearchBar } from "./SearchBar"; +import type { SearchField } from "../features/packets/types"; + +interface FilterOption { + value: string; + label: string; +} + +interface FilterBarProps { + typeOptions: FilterOption[]; + routeOptions: FilterOption[]; + observerOptions: FilterOption[]; + activeTypes: string[]; + activeRoutes: string[]; + activeObservers: string[]; + onTypesChange: (values: string[]) => void; + onRoutesChange: (values: string[]) => void; + onObserversChange: (values: string[]) => void; + search: string; + onSearchChange: (value: string) => void; + searchField: SearchField; + onSearchFieldChange: (field: SearchField) => void; + onClear: () => void; +} + +// toolbar combining search + multi-select filter dropdowns + +export function FilterBar({ + typeOptions, + routeOptions, + observerOptions, + activeTypes, + activeRoutes, + activeObservers, + onTypesChange, + onRoutesChange, + onObserversChange, + search, + onSearchChange, + searchField, + onSearchFieldChange, + onClear, +}: FilterBarProps) { + const hasFilters = activeTypes.length > 0 || activeRoutes.length > 0 || activeObservers.length > 0 || search.length > 0; + + return ( +
+ + + + + + + + + {hasFilters && ( + + )} +
+ ); +} diff --git a/src/components/HopBadge.tsx b/src/components/HopBadge.tsx new file mode 100644 index 0000000..4187ae5 --- /dev/null +++ b/src/components/HopBadge.tsx @@ -0,0 +1,26 @@ +import type { Observation } from "../types/api"; +import { VARIANT_CLASSES } from "./badge-utils"; + +// renders a path hop with confidence-based styling + +export function HopBadge({ hop }: { hop: Observation["resolvedPath"][number] }) { + if (hop.confidence === "high" && hop.node) { + return ( + + {hop.node.name} + + ); + } + if (hop.confidence === "ambiguous" && hop.candidates) { + return ( + + {hop.idBytes?.toUpperCase()} ({hop.candidates.length} candidates) + + ); + } + return ( + + {hop.idBytes?.toUpperCase() ?? "??"} + + ); +} diff --git a/src/components/MultiSelectDropdown.tsx b/src/components/MultiSelectDropdown.tsx new file mode 100644 index 0000000..2124ea1 --- /dev/null +++ b/src/components/MultiSelectDropdown.tsx @@ -0,0 +1,161 @@ +import { useState, useRef, useEffect, useMemo, useCallback } from "react"; +import { useClickOutside } from "../hooks/useClickOutside"; + +interface Option { + value: string; + label: string; +} + +interface MultiSelectDropdownProps { + label: string; + options: Option[]; + selected: string[]; + onChange: (selected: string[]) => void; + searchable?: boolean; + align?: "left" | "right"; +} + +// checkbox dropdown with optional search filter + +export function MultiSelectDropdown({ label, options, selected, onChange, searchable, align = "left" }: MultiSelectDropdownProps) { + const [open, setOpen] = useState(false); + const [filter, setFilter] = useState(""); + const ref = useRef(null); + const inputRef = useRef(null); + + const showSearch = searchable ?? options.length > 6; + + const closeDropdown = useCallback(() => setOpen(false), []); + useClickOutside(ref, open, closeDropdown); + + useEffect(() => { + if (!open) return; + function handleKey(e: KeyboardEvent) { + if (e.key === "Escape") setOpen(false); + } + document.addEventListener("keydown", handleKey); + return () => document.removeEventListener("keydown", handleKey); + }, [open]); + + useEffect(() => { + if (open && showSearch) { + inputRef.current?.focus(); + } + }, [open, showSearch]); + + const filtered = useMemo(() => { + if (!filter) return options; + const q = filter.toLowerCase(); + return options.filter((o) => o.label.toLowerCase().includes(q)); + }, [options, filter]); + + const count = selected.length; + + return ( +
+ + + {open && ( +
+ {showSearch && ( +
+ setFilter(e.target.value)} + placeholder="Filter..." + className="w-full text-[11px] font-mono bg-bg-surface border border-border rounded px-2 py-1 text-text-bright placeholder:text-text-dim outline-none focus:border-primary-dim" + /> +
+ )} + +
+ + · + +
+ +
+ {filtered.map((opt) => { + const isSelected = selected.includes(opt.value); + return ( + + ); + })} + {filtered.length === 0 && ( +
No matches
+ )} +
+
+ )} +
+ ); +} diff --git a/src/components/SearchBar.tsx b/src/components/SearchBar.tsx new file mode 100644 index 0000000..998f7ab --- /dev/null +++ b/src/components/SearchBar.tsx @@ -0,0 +1,119 @@ +import { useState, useRef, useEffect } from "react"; +import { Dropdown } from "./Dropdown"; +import type { SearchField } from "../features/packets/types"; + +interface SearchBarProps { + value: string; + onChange: (value: string) => void; + field: SearchField; + onFieldChange: (field: SearchField) => void; +} + +// search field selector config + +const FIELDS: { value: SearchField; label: string; disabled: boolean }[] = [ + { value: "hash", label: "Hash", disabled: false }, + { value: "path", label: "Path", disabled: true }, + { value: "payload", label: "Payload", disabled: true }, +]; + +// debounced search input with field dropdown + +export function SearchBar({ value, onChange, field, onFieldChange }: SearchBarProps) { + const [localValue, setLocalValue] = useState(value); + const [prevValue, setPrevValue] = useState(value); + if (prevValue !== value) { + setPrevValue(value); + setLocalValue(value); + } + + const debounceRef = useRef | null>(null); + + useEffect(() => { + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }; + }, []); + + function handleChange(val: string) { + setLocalValue(val); + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { + onChange(val); + }, 300); + } + + // shouldn't happen but TS doesn't narrow after .find() + const currentField = FIELDS.find((f) => f.value === field) ?? { value: "hash" as const, label: "Hash", disabled: false }; + + return ( +
+ ( + + )} + > + {(close) => FIELDS.map((f) => ( + + ))} + + +
+ + + + + handleChange(e.target.value)} + placeholder={`Search by ${currentField.label.toLowerCase()}...`} + className="w-full text-[11px] font-mono bg-bg-surface border border-border rounded-r-sm pl-7 pr-7 py-1 text-text-bright placeholder:text-text-dim outline-none focus:border-primary-dim transition-colors" + /> + {localValue && ( + + )} +
+
+ ); +} diff --git a/src/components/badge-utils.ts b/src/components/badge-utils.ts new file mode 100644 index 0000000..78b2946 --- /dev/null +++ b/src/components/badge-utils.ts @@ -0,0 +1,37 @@ +// variant styles for badge colors + +export type BadgeVariant = "advert" | "text" | "trace" | "ack" | "group" | "request" | "default" | "live" | "stale" | "offline"; + +export const VARIANT_CLASSES: Record = { + advert: "bg-green/8 text-green border-green/12", + text: "bg-primary/6 text-primary border-primary/12", + trace: "bg-warn/7 text-warn border-warn/12", + ack: "bg-text-muted/10 text-text-muted border-text-muted/12", + group: "bg-secondary/6 text-secondary border-secondary/12", + request: "bg-warn/7 text-warn border-warn/12", + default: "bg-text-muted/10 text-text-muted border-text-muted/12", + live: "bg-green/8 text-green border-green/15", + stale: "bg-warn/7 text-warn border-warn/15", + offline: "bg-danger/8 text-danger border-danger/15", +}; + +// maps MeshCore payload type codes to badge variants + +const PAYLOAD_TYPE_TO_VARIANT: Record = { + 0x00: "request", + 0x01: "request", + 0x02: "text", + 0x03: "ack", + 0x04: "advert", + 0x05: "group", + 0x06: "group", + 0x07: "request", + 0x08: "default", + 0x09: "trace", + 0x0a: "default", + 0x0f: "default", +}; + +export function payloadTypeVariant(payloadType: number): BadgeVariant { + return PAYLOAD_TYPE_TO_VARIANT[payloadType] ?? "default"; +} diff --git a/src/features/channels/ChannelList.tsx b/src/features/channels/ChannelList.tsx new file mode 100644 index 0000000..11860a4 --- /dev/null +++ b/src/features/channels/ChannelList.tsx @@ -0,0 +1,7 @@ +export function ChannelList() { + return ( +
+ Channels — coming soon +
+ ); +} diff --git a/src/features/channels/types.ts b/src/features/channels/types.ts new file mode 100644 index 0000000..c08771a --- /dev/null +++ b/src/features/channels/types.ts @@ -0,0 +1,14 @@ +export interface Channel { + channelHash: string; + memberCount: number; + lastActivityAt: number; +} + +export interface ChannelMessage { + id: string; + channelHash: string; + senderName: string | null; + content: string; + decrypted: boolean; + timestamp: number; +} diff --git a/src/features/map/MapView.tsx b/src/features/map/MapView.tsx new file mode 100644 index 0000000..d737f7a --- /dev/null +++ b/src/features/map/MapView.tsx @@ -0,0 +1,7 @@ +export function MapView() { + return ( +
+ Map — coming soon +
+ ); +} diff --git a/src/features/map/types.ts b/src/features/map/types.ts new file mode 100644 index 0000000..debc2f9 --- /dev/null +++ b/src/features/map/types.ts @@ -0,0 +1,16 @@ +export interface MapObserver { + id: string; + name: string; + iata: string; + lat: number; + lng: number; + online: boolean; +} + +export interface PacketArc { + fromLat: number; + fromLng: number; + toLat: number; + toLng: number; + packetHash: string; +} diff --git a/src/features/nodes/NodeTable.tsx b/src/features/nodes/NodeTable.tsx new file mode 100644 index 0000000..b0d8d37 --- /dev/null +++ b/src/features/nodes/NodeTable.tsx @@ -0,0 +1,7 @@ +export function NodeTable() { + return ( +
+ Nodes — coming soon +
+ ); +} diff --git a/src/features/nodes/types.ts b/src/features/nodes/types.ts new file mode 100644 index 0000000..2496653 --- /dev/null +++ b/src/features/nodes/types.ts @@ -0,0 +1,11 @@ +export interface Node { + id: string; + shortId: string; + name: string; + publicKey: string; + iata: string | null; + firmware: string | null; + lastSeenAt: number; + latitude: number | null; + longitude: number | null; +} diff --git a/src/features/observers/ObserverTable.tsx b/src/features/observers/ObserverTable.tsx new file mode 100644 index 0000000..add55ff --- /dev/null +++ b/src/features/observers/ObserverTable.tsx @@ -0,0 +1,7 @@ +export function ObserverTable() { + return ( +
+ Observers — coming soon +
+ ); +} diff --git a/src/features/observers/types.ts b/src/features/observers/types.ts new file mode 100644 index 0000000..be2e632 --- /dev/null +++ b/src/features/observers/types.ts @@ -0,0 +1,13 @@ +export interface Observer { + id: string; + displayName: string; + iata: string; + online: boolean; + lastSeenAt: number; + brokers: string[]; + telemetry: { + batteryPct: number | null; + uptimeSec: number | null; + queueDepth: number | null; + } | null; +} diff --git a/src/features/packets/ObservationCard.tsx b/src/features/packets/ObservationCard.tsx new file mode 100644 index 0000000..df06a3d --- /dev/null +++ b/src/features/packets/ObservationCard.tsx @@ -0,0 +1,65 @@ +import type { Observation } from "../../types/api"; +import { formatTimeOnly, formatSnr, snrLevel, formatPropagation, SIGNAL_LEVEL_CLASSES } from "../../lib/formatters"; +import { HopBadge } from "../../components/HopBadge"; + +// single observation with signal stats and resolved path + +export function ObservationCard({ observation: obs, selected, onClick }: { observation: Observation; selected?: boolean; onClick?: () => void }) { + const level = snrLevel(obs.snr); + + return ( +
+
+ {obs.observerName} + + {obs.iata} + + + {formatTimeOnly(obs.heardAt)} + +
+ +
+
+ SNR + + {formatSnr(obs.snr)} + +
+
+ RSSI + + {obs.rssi ?? "—"} + +
+
+ Prop + {formatPropagation(obs.propagationTimeMs)} +
+
+ Hops + {obs.hopCount} +
+
+ + {obs.resolvedPath.length > 0 && ( +
+ Path + {obs.resolvedPath.map((hop, i) => ( + + {i > 0 && } + + + ))} +
+ )} +
+ ); +} diff --git a/src/features/packets/PacketAnalyzerDrawer.tsx b/src/features/packets/PacketAnalyzerDrawer.tsx new file mode 100644 index 0000000..ff86f6e --- /dev/null +++ b/src/features/packets/PacketAnalyzerDrawer.tsx @@ -0,0 +1,205 @@ +import type { PacketDetail } from "../../types/api"; +import { PayloadType, PAYLOAD_TYPE_NAMES, ROUTE_TYPE_NAMES, type PayloadTypeValue, type RouteTypeValue } from "../../types/enums"; +import { Badge } from "../../components/Badge"; +import { payloadTypeVariant } from "../../components/badge-utils"; +import { formatHex, formatTimestamp } from "../../lib/formatters"; +import { computeFieldRanges, ColoredHexDump, HeaderBitBreakdown, PathLengthBitBreakdown, ColorAccentField, DrawerSection, ObservationDetail } from "./packet-structure"; +import { PayloadBreakdown } from "./payload-renderers"; + +interface PacketAnalyzerDrawerProps { + detail: PacketDetail | undefined; + selectedObservationId: number | null; + open: boolean; + onToggle: () => void; +} + +// collapsible side panel showing packet structure and payload breakdown + +export function PacketAnalyzerDrawer({ detail, selectedObservationId, open, onToggle }: PacketAnalyzerDrawerProps) { + const selectedObs = detail?.observations.find((o) => o.id === selectedObservationId) + ?? detail?.observations[0] + ?? null; + + const rawHex = selectedObs?.rawPacket ?? detail?.rawPayload ?? ""; + const hasStructure = !!selectedObs?.rawPacket; + const totalBytes = rawHex.length / 2; + + const fieldRanges = detail && hasStructure + ? computeFieldRanges(detail, selectedObs, totalBytes) + : {}; + + const headerHex = hasStructure ? rawHex.slice(0, 2) : detail?.headerByte ?? ""; + + if (!open) { + return ( +
+ + {detail && ( +
+ {formatHex(detail.packetHash)} +
+ )} +
+ ); + } + + return ( +
+
+ Packet Analyzer + +
+ +
+ {!detail ? ( +
+ + + + + + Select a packet to analyze +
+ ) : ( + <> + +
+ + {formatHex(detail.packetHash)} + + + {PAYLOAD_TYPE_NAMES[detail.payloadType as PayloadTypeValue] ?? "Unknown"} + + + ×{detail.observations.length} + +
+ {detail.summary && ( +
+ {detail.summary} +
+ )} +
+ First {formatTimestamp(detail.firstHeardAt)} + · + Last {formatTimestamp(detail.lastHeardAt)} +
+
+ + {selectedObs && ( + + + + )} + + {rawHex && ( + +
+ {hasStructure ? ( + + ) : ( +
+                      {(rawHex.match(/.{1,2}/g) ?? []).reduce((acc, b, i) => {
+                        const sep = i > 0 && i % 16 === 0 ? "\n" : i > 0 ? " " : "";
+                        return acc + sep + b.toUpperCase();
+                      }, "")}
+                    
+ )} +
+
+ )} + + +
+ {/* Header byte */} + +
Header Byte
+
+ Ver {detail.payloadVersion} + Type {PAYLOAD_TYPE_NAMES[detail.payloadType as PayloadTypeValue] ?? "?"} ({detail.payloadType}) + Route {ROUTE_TYPE_NAMES[detail.routeType as RouteTypeValue] ?? "?"} ({detail.routeType}) +
+ {headerHex && ( + + )} +
+ + {/* Transport codes */} + {detail.transportCodes && ( + + Transport + {detail.transportCodes.regionCode} / {detail.transportCodes.subRegionCode} + + )} + + {/* Path length */} + {selectedObs && ( + +
Path Length
+
+ Hash Size {selectedObs.hashSize}B + Hops {selectedObs.hopCount} +
+ +
+ )} + + {/* Path data */} + {selectedObs?.pathBytes && ( + +
Path Data
+
+ {(() => { + const chars = selectedObs.hashSize * 2; + const hops = selectedObs.pathBytes.match(new RegExp(`.{1,${chars}}`, "g")) ?? []; + return hops.map((hop, i) => ( + + {i > 0 && } + {hop.toUpperCase()} + + )); + })()} +
+
+ )} + + {detail.originPubkey && detail.payloadType !== PayloadType.ADVERT && ( + + Origin Pubkey +
{detail.originPubkey}
+
+ )} +
+
+ + {detail.parsedPayload && Object.keys(detail.parsedPayload).length > 0 && ( + +
+ +
+
+ )} + + )} +
+
+ ); +} diff --git a/src/features/packets/PacketList.tsx b/src/features/packets/PacketList.tsx new file mode 100644 index 0000000..e346583 --- /dev/null +++ b/src/features/packets/PacketList.tsx @@ -0,0 +1,168 @@ +import { useState, useCallback, useRef, useMemo } from "react"; +import { useQuery, keepPreviousData } from "@tanstack/react-query"; +import { usePackets } from "./usePackets"; +import { usePacketFilters, matchesFilters } from "./usePacketFilters"; +import { useWsPacketHandler, useWsLaggedHandler } from "../../hooks/useWsHandlers"; +import { PacketVirtualList } from "./PacketVirtualList"; +import { PacketAnalyzerDrawer } from "./PacketAnalyzerDrawer"; +import { FilterBar } from "../../components/FilterBar"; +import { getPacketDetail } from "../../api/client"; +import { PAYLOAD_TYPE_NAMES, ROUTE_TYPE_NAMES } from "../../types/enums"; +import type { WsManager } from "../../api/ws-manager"; + +// filter options and storage keys + +const TYPE_OPTIONS = Object.entries(PAYLOAD_TYPE_NAMES).map(([value, label]) => ({ + value: String(value), + label, +})); + +const ROUTE_OPTIONS = Object.entries(ROUTE_TYPE_NAMES).map(([value, label]) => ({ + value: String(value), + label, +})); + +const DRAWER_STORAGE_KEY = "tower-analyzer-open"; + +// main packet view: filters, banner, virtual list, analyzer + +export function PacketList({ wsManager }: { wsManager: WsManager }) { + const { filters, setFilter, setSearch, setSearchField, clearFilters } = usePacketFilters(); + const { + allPackets, + observerOptions, + newPacketCount, + acknowledgeNewPackets, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + observersByHash, + handlePacketObservation, + handleLagged, + laggedCount, + dismissLagged, + } = usePackets(); + + const packets = useMemo( + () => allPackets.filter((p) => matchesFilters(p, filters, observersByHash)), + [allPackets, filters, observersByHash], + ); + + const [isScrolledAway, setIsScrolledAway] = useState(false); + const scrollToTopRef = useRef<(() => void) | null>(null); + + const [expandedHash, setExpandedHash] = useState(null); + const [selectedObservationId, setSelectedObservationId] = useState(null); + const [drawerOpen, setDrawerOpen] = useState(() => { + const stored = localStorage.getItem(DRAWER_STORAGE_KEY); + return stored === null ? true : stored === "true"; + }); + + const expandedPacket = packets.find((p) => p.packetHash === expandedHash); + + const { data: expandedDetail } = useQuery({ + queryKey: ["packet-detail", expandedHash, expandedPacket?.observationCount], + queryFn: () => getPacketDetail(expandedHash!), + enabled: !!expandedHash, + staleTime: Infinity, + placeholderData: keepPreviousData, + }); + + const handleToggleExpand = useCallback((hash: string) => { + setExpandedHash((prev) => { + if (prev === hash) { + setSelectedObservationId(null); + return null; + } + setSelectedObservationId(null); + return hash; + }); + }, []); + + const handleToggleDrawer = useCallback(() => { + setDrawerOpen((prev) => { + const next = !prev; + localStorage.setItem(DRAWER_STORAGE_KEY, String(next)); + return next; + }); + }, []); + + useWsPacketHandler(wsManager, handlePacketObservation); + useWsLaggedHandler(wsManager, handleLagged); + + const bannerCount = isScrolledAway ? newPacketCount : 0; + + const handleScrolledAway = useCallback( + (isAway: boolean) => { + setIsScrolledAway(isAway); + if (!isAway) acknowledgeNewPackets(); + }, + [acknowledgeNewPackets], + ); + + const handleScrollToTop = useCallback(() => { + scrollToTopRef.current?.(); + acknowledgeNewPackets(); + }, [acknowledgeNewPackets]); + + return ( +
+
+ setFilter("payloadTypes", v.map(Number))} + onRoutesChange={(v) => setFilter("routeTypes", v.map(Number))} + onObserversChange={(v) => setFilter("observers", v)} + search={filters.search} + onSearchChange={setSearch} + searchField={filters.searchField} + onSearchFieldChange={setSearchField} + onClear={clearFilters} + /> + + {laggedCount > 0 && ( +
+ {laggedCount} packet{laggedCount === 1 ? "" : "s"} dropped — data may be incomplete + +
+ )} + + {bannerCount > 0 && ( + + )} + + +
+ + +
+ ); +} diff --git a/src/features/packets/PacketRow.tsx b/src/features/packets/PacketRow.tsx new file mode 100644 index 0000000..981bfa6 --- /dev/null +++ b/src/features/packets/PacketRow.tsx @@ -0,0 +1,83 @@ +import { formatHex, formatTimestamp } from "../../lib/formatters"; +import type { PacketSummary, PacketDetail } from "../../types/api"; +import { Badge } from "../../components/Badge"; +import { payloadTypeVariant } from "../../components/badge-utils"; +import { ObservationCard } from "./ObservationCard"; + +interface PacketRowProps { + packet: PacketSummary; + expanded: boolean; + detail?: PacketDetail; + isFresh?: boolean; + onToggle: () => void; + selectedObservationId?: number | null; + onSelectObservation?: (id: number) => void; +} + +// expandable packet card with observations + +export function PacketRow({ packet, expanded, detail, isFresh, onToggle, selectedObservationId, onSelectObservation }: PacketRowProps) { + return ( +
onToggle()} + aria-expanded={expanded} + tabIndex={0} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onToggle(); + } + }} + > +
+ + {expanded ? "▾" : "▸"} + + + {formatHex(packet.packetHash)} + + {packet.payloadTypeName} + + {packet.summary} + + + ×{packet.observationCount} + +
+ +
+ + {packet.routeTypeName || "Unknown"} + + · + {formatTimestamp(packet.lastHeardAt)} + · + {packet.latestObserver.displayName} + · + + {packet.latestObserver.iata} + +
+ + {expanded && detail && ( +
e.stopPropagation()}> + {detail.observations.map((obs) => ( + onSelectObservation(obs.id) : undefined} + /> + ))} +
+ )} +
+ ); +} diff --git a/src/features/packets/PacketVirtualList.tsx b/src/features/packets/PacketVirtualList.tsx new file mode 100644 index 0000000..ed61d5a --- /dev/null +++ b/src/features/packets/PacketVirtualList.tsx @@ -0,0 +1,193 @@ +import { useRef, useState, useCallback, useEffect, useLayoutEffect } from "react"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import type { PacketSummary, PacketDetail } from "../../types/api"; +import { PacketRow } from "./PacketRow"; +import { LIVE_BUFFER_CAP, SCROLL_TOP_THRESHOLD_PX, SCROLL_BOTTOM_THRESHOLD_PX } from "../../lib/constants"; + +interface PacketVirtualListProps { + packets: PacketSummary[]; + hasNextPage: boolean; + isFetchingNextPage: boolean; + fetchNextPage: () => void; + onScrollAwayFromTop: (isAway: boolean) => void; + scrollToTopRef?: React.MutableRefObject<(() => void) | null>; + expandedHash: string | null; + expandedDetail?: PacketDetail; + onToggleExpand: (hash: string) => void; + selectedObservationId: number | null; + onSelectObservation: (id: number) => void; +} + +// virtualized scroll list with fresh-item highlighting and infinite load + +export function PacketVirtualList({ + packets, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + onScrollAwayFromTop, + scrollToTopRef, + expandedHash, + expandedDetail, + onToggleExpand, + selectedObservationId, + onSelectObservation, +}: PacketVirtualListProps) { + const parentRef = useRef(null); + const knownHashes = useRef>(new Set()); + const [freshHashes, setFreshHashes] = useState>(new Set()); + const freshTimerRef = useRef | null>(null); + const isAtTopRef = useRef(true); + const prevCountRef = useRef(packets.length); + const savedScrollHeightRef = useRef(0); + const shouldCompensateRef = useRef(false); + + useLayoutEffect(() => { + if (packets.length > prevCountRef.current && !isAtTopRef.current) { + savedScrollHeightRef.current = parentRef.current?.scrollHeight ?? 0; + shouldCompensateRef.current = true; + } + }, [packets.length]); + + const virtualizer = useVirtualizer({ + count: packets.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 64, // rough -- rows vary a lot when expanded, tanstack remeasures + overscan: 10, + getItemKey: (index) => packets[index]?.packetHash ?? index, + }); + + const hadDataRef = useRef(false); + + useEffect(() => { + if (!hadDataRef.current) { + if (packets.length === 0) return; + hadDataRef.current = true; + for (const p of packets) { + knownHashes.current.add(p.packetHash); + } + return; + } + + const newFresh: string[] = []; + for (const p of packets) { + if (knownHashes.current.has(p.packetHash)) break; + newFresh.push(p.packetHash); + } + + for (const p of packets) { + knownHashes.current.add(p.packetHash); + } + + if (knownHashes.current.size > LIVE_BUFFER_CAP * 2) { + const keep = new Set(packets.map((p) => p.packetHash)); + knownHashes.current = keep; + } + + if (newFresh.length > 0) { + setFreshHashes((prev) => { + const next = new Set(prev); + for (const h of newFresh) next.add(h); + return next; + }); + if (freshTimerRef.current) clearTimeout(freshTimerRef.current); + freshTimerRef.current = setTimeout(() => { + freshTimerRef.current = null; + setFreshHashes((prev) => { + const next = new Set(prev); + for (const h of newFresh) next.delete(h); + return next; + }); + }, 1000); + } + + return () => { + if (freshTimerRef.current) { + clearTimeout(freshTimerRef.current); + freshTimerRef.current = null; + } + }; + }, [packets]); + + useLayoutEffect(() => { + prevCountRef.current = packets.length; + if (shouldCompensateRef.current) { + shouldCompensateRef.current = false; + const el = parentRef.current; + if (el) { + const delta = el.scrollHeight - savedScrollHeightRef.current; + if (delta > 0) el.scrollTop += delta; + } + } + }, [packets.length]); + + const handleScroll = useCallback(() => { + const el = parentRef.current; + if (!el) return; + + isAtTopRef.current = el.scrollTop <= SCROLL_TOP_THRESHOLD_PX; + onScrollAwayFromTop(!isAtTopRef.current); + + if (hasNextPage && !isFetchingNextPage) { + const distFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; + if (distFromBottom < SCROLL_BOTTOM_THRESHOLD_PX) { + fetchNextPage(); + } + } + }, [hasNextPage, isFetchingNextPage, fetchNextPage, onScrollAwayFromTop]); + + useEffect(() => { + if (!scrollToTopRef) return; + scrollToTopRef.current = () => { + const el = parentRef.current; + if (el) { + isAtTopRef.current = true; + el.scrollTop = 0; + onScrollAwayFromTop(false); + } + }; + }, [scrollToTopRef, onScrollAwayFromTop]); + + return ( +
+
+ {virtualizer.getVirtualItems().map((virtualRow) => { + const packet = packets[virtualRow.index]; + if (!packet) return null; + return ( +
+
+ onToggleExpand(packet.packetHash)} + selectedObservationId={expandedHash === packet.packetHash ? selectedObservationId : null} + onSelectObservation={onSelectObservation} + /> +
+
+ ); + })} +
+
+ ); +} diff --git a/src/features/packets/packet-structure.tsx b/src/features/packets/packet-structure.tsx new file mode 100644 index 0000000..c4ad4e2 --- /dev/null +++ b/src/features/packets/packet-structure.tsx @@ -0,0 +1,355 @@ +/* eslint-disable react-refresh/only-export-components */ +import { Fragment } from "react"; +import type { PacketDetail, Observation } from "../../types/api"; +import { RouteType } from "../../types/enums"; +import { HopBadge } from "../../components/HopBadge"; +import { formatTimeOnly, formatSnr, snrLevel, formatPropagation, SIGNAL_LEVEL_CLASSES } from "../../lib/formatters"; + +// maps packet bytes to named field ranges for hex coloring + +export type FieldId = "header" | "transport" | "pathLength" | "pathData" | "payload" | "channelHash" | "cipherMac" | "ciphertext" | "publicKey" | "signature" | "advertTimestamp" | "flags" | "location" | "advertName" | "destinationHash" | "sourceHash" | "senderPublicKey" | "checksum"; + +export interface ByteRange { + start: number; + end: number; +} + +export function computeFieldRanges( + detail: PacketDetail, + obs: Observation | null, + totalBytes: number, +): Partial> { + const ranges: Partial> = {}; + + ranges.header = { start: 0, end: 1 }; + let offset = 1; + + const hasTransport = + detail.routeType === RouteType.TRANSPORT_FLOOD || + detail.routeType === RouteType.TRANSPORT_DIRECT; + if (hasTransport) { + ranges.transport = { start: offset, end: offset + 4 }; + offset += 4; + } + + if (obs) { + ranges.pathLength = { start: offset, end: offset + 1 }; + offset += 1; + + const pathByteCount = obs.hashSize * obs.hopCount; + if (pathByteCount > 0) { + ranges.pathData = { start: offset, end: offset + pathByteCount }; + } + offset += pathByteCount; + } + + if (offset < totalBytes) { + ranges.payload = { start: offset, end: totalBytes }; + + const pp = detail.parsedPayload; + if (pp) { + if (pp.type === "ADVERT" && typeof pp.publicKey === "string") { + let sub = offset; + const pkLen = (pp.publicKey as string).length / 2; + ranges.publicKey = { start: sub, end: sub + pkLen }; + sub += pkLen; + + ranges.advertTimestamp = { start: sub, end: sub + 4 }; + sub += 4; + + if (typeof pp.signature === "string") { + const sigLen = (pp.signature as string).length / 2; + ranges.signature = { start: sub, end: sub + sigLen }; + sub += sigLen; + } + + if (sub < totalBytes) { + ranges.flags = { start: sub, end: sub + 1 }; + sub += 1; + } + + if (pp.hasLocation && sub + 8 <= totalBytes) { + ranges.location = { start: sub, end: sub + 8 }; + sub += 8; + } + if (pp.hasFeature1) sub += 2; + if (pp.hasFeature2) sub += 2; + if (pp.hasName && sub < totalBytes) { + ranges.advertName = { start: sub, end: totalBytes }; + } + } else { + let hexFields: FieldId[]; + if (pp.type === "ACK" && pp.checksum) { + hexFields = ["checksum"]; + } else if (pp.senderPublicKey) { + hexFields = ["destinationHash", "senderPublicKey", "cipherMac", "ciphertext"]; + } else if (pp.destinationHash || pp.sourceHash) { + hexFields = ["destinationHash", "sourceHash", "cipherMac", "ciphertext"]; + } else if (pp.channelHash) { + hexFields = ["channelHash", "cipherMac", "ciphertext"]; + } else { + hexFields = []; + } + let subOffset = offset; + for (const key of hexFields) { + const val = pp[key]; + if (typeof val !== "string" || !/^[0-9a-fA-F]+$/.test(val)) break; + const byteLen = val.length / 2; + ranges[key] = { start: subOffset, end: subOffset + byteLen }; + subOffset += byteLen; + } + } + } + } + + return ranges; +} + +function formatBinary(byte: number): string { + return byte.toString(2).padStart(8, "0"); +} + +// drawer UI: hex dump, bit breakdowns, field accents + +export const FIELD_COLORS: Record = { + header: { hex: "text-secondary", accent: "border-l-secondary/50" }, + transport: { hex: "text-warn", accent: "border-l-warn/50" }, + pathLength: { hex: "text-primary", accent: "border-l-primary/50" }, + pathData: { hex: "text-green", accent: "border-l-green/50" }, + payload: { hex: "text-text-muted", accent: "border-l-text-muted/50" }, + channelHash: { hex: "text-primary", accent: "border-l-primary/50" }, + cipherMac: { hex: "text-warn", accent: "border-l-warn/50" }, + ciphertext: { hex: "text-danger", accent: "border-l-danger/50" }, + publicKey: { hex: "text-green", accent: "border-l-green/50" }, + signature: { hex: "text-secondary", accent: "border-l-secondary/50" }, + advertTimestamp: { hex: "text-primary", accent: "border-l-primary/50" }, + flags: { hex: "text-warn", accent: "border-l-warn/50" }, + location: { hex: "text-green", accent: "border-l-green/50" }, + advertName: { hex: "text-text-normal", accent: "border-l-text-muted/50" }, + destinationHash: { hex: "text-primary", accent: "border-l-primary/50" }, + sourceHash: { hex: "text-secondary", accent: "border-l-secondary/50" }, + senderPublicKey: { hex: "text-green", accent: "border-l-green/50" }, + checksum: { hex: "text-warn", accent: "border-l-warn/50" }, +}; + +export function DrawerSection({ title, children, first }: { title: string; children: React.ReactNode; first?: boolean }) { + return ( +
+
{title}
+ {children} +
+ ); +} + +export function ColoredHexDump({ + data, + ranges, +}: { + data: string; + ranges: Partial>; +}) { + const bytes = data.match(/.{1,2}/g) ?? []; + + const byteFieldMap = new Map(); + for (const [field, range] of Object.entries(ranges) as [FieldId, ByteRange][]) { + for (let i = range.start; i < range.end; i++) { + byteFieldMap.set(i, field); + } + } + + return ( +
+      {bytes.map((byte, i) => {
+        const field = byteFieldMap.get(i) ?? null;
+        const colorClass = field ? FIELD_COLORS[field].hex : "text-text-muted";
+        const isLineStart = i % 16 === 0 && i > 0;
+
+        return (
+          
+            {isLineStart && "\n"}
+            {i % 16 !== 0 && " "}
+            {byte.toUpperCase()}
+          
+        );
+      })}
+    
+ ); +} + +export function HeaderBitBreakdown({ headerHex }: { headerHex: string }) { + const value = parseInt(headerHex, 16); + if (Number.isNaN(value)) return null; + + const bits = formatBinary(value); + const ver = bits.slice(0, 2); + const typ = bits.slice(2, 6); + const route = bits.slice(6, 8); + + return ( +
+
+ 0x{headerHex.toUpperCase()} = [ + + {ver} + ver + + | + + {typ} + type + + | + + {route} + route + + ] +
+
+ ); +} + +export function PathLengthBitBreakdown({ pathLengthByte }: { pathLengthByte: number }) { + const bits = formatBinary(pathLengthByte); + const hashBits = bits.slice(0, 2); + const hopBits = bits.slice(2, 8); + + const hashSize = (pathLengthByte >> 6) & 0x03; + const hopCount = pathLengthByte & 0x3f; + + return ( +
+
+ 0x{pathLengthByte.toString(16).toUpperCase().padStart(2, "0")} = [ + + {hashBits} + hash={hashSize + 1}B + + | + + {hopBits} + hops={hopCount} + + ] +
+
+ ); +} + +export function AdvertFlagsBitBreakdown({ flagsByte }: { flagsByte: number }) { + const bits = formatBinary(flagsByte); + const roleBits = bits.slice(4, 8); + const loc = bits[3]; + const f1 = bits[2]; + const f2 = bits[1]; + const nm = bits[0]; + + const role = flagsByte & 0x0f; + const DEVICE_ROLE_NAMES: Record = { + 0x01: "ChatNode", + 0x02: "Repeater", + 0x03: "RoomServer", + 0x04: "Sensor", + }; + const roleName = DEVICE_ROLE_NAMES[role] ?? `0x${role.toString(16)}`; + + return ( +
+
+ 0x{flagsByte.toString(16).toUpperCase().padStart(2, "0")} = [ + + {roleBits} + {roleName} + + | + + {loc} + loc + + | + + {f1} + f1 + + | + + {f2} + f2 + + | + + {nm} + name + + ] +
+
+ ); +} + +export function ColorAccentField({ + field, + children, + className, +}: { + field: FieldId; + children: React.ReactNode; + className?: string; +}) { + return ( +
+ {children} +
+ ); +} + +export function ObservationDetail({ observation }: { observation: Observation }) { + const level = snrLevel(observation.snr); + const sigClass = level ? SIGNAL_LEVEL_CLASSES[level] : "text-text-normal"; + + return ( +
+
+ {observation.observerName} + + {observation.iata} + + + {formatTimeOnly(observation.heardAt)} + +
+ +
+ SNR {formatSnr(observation.snr)} + RSSI {observation.rssi ?? "—"} + Prop {formatPropagation(observation.propagationTimeMs)} + Hops {observation.hopCount} +
+ + {observation.radio && ( +
+ Radio + {observation.radio.freqMhz} MHz + · + SF{observation.radio.spreadFactor} + · + {observation.radio.bandwidthKhz} kHz + · + CR 4/{observation.radio.codingRate} +
+ )} + + {observation.resolvedPath.length > 0 && ( +
+ Path + {observation.resolvedPath.map((hop, i) => ( + + {i > 0 && } + + + ))} +
+ )} +
+ ); +} diff --git a/src/features/packets/payload-renderers.tsx b/src/features/packets/payload-renderers.tsx new file mode 100644 index 0000000..bda7034 --- /dev/null +++ b/src/features/packets/payload-renderers.tsx @@ -0,0 +1,675 @@ +import type { ReactNode } from "react"; +import { Badge } from "../../components/Badge"; +import { formatSnr, snrLevel, formatTimestamp, SIGNAL_LEVEL_CLASSES } from "../../lib/formatters"; +import { ColorAccentField, AdvertFlagsBitBreakdown, PathLengthBitBreakdown, FIELD_COLORS } from "./packet-structure"; +import type { FieldId } from "./packet-structure"; + +// shared layout primitives for payload fields + +interface PayloadProps { + payload: Record; +} + +function SectionLabel({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +function FieldRow({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ {label} + {children} +
+ ); +} + +function TruncatedHex({ label, value, maxChars = 16, accentClass }: { + label: string; + value: string; + maxChars?: number; + accentClass?: string; +}) { + const display = value.length > maxChars ? value.slice(0, maxChars).toUpperCase() + "…" : value.toUpperCase(); + const bytes = value.length / 2; + return ( +
+ {label} + {display} + ({bytes}B) +
+ ); +} + +function HexBadge({ value }: { value: string }) { + return ( + + {value.toUpperCase()} + + ); +} + +function BooleanValue({ value }: { value: boolean }) { + return {value ? "Yes" : "No"}; +} + +function FlagChip({ label, active, variant = "green" }: { label: string; active: boolean; variant?: "green" | "danger" }) { + const activeClasses = variant === "danger" + ? "bg-danger/8 text-danger border-danger/15" + : "bg-green/8 text-green border-green/15"; + return ( + + {label} + + ); +} + +function EncryptedIndicator() { + return ( +
+ + + + + Encrypted — key not available +
+ ); +} + +function EncryptedEnvelope({ payload, headerSlot, children }: { + payload: Record; + headerSlot?: ReactNode; + children: (decrypted: Record) => ReactNode; +}) { + const destinationHash = payload.destinationHash as string | undefined; + const sourceHash = payload.sourceHash as string | undefined; + const cipherMac = payload.cipherMac as string | undefined; + const ciphertext = payload.ciphertext as string | undefined; + const decrypted = payload.decrypted as Record | null | undefined; + + return ( +
+
+ {destinationHash && ( + + To + ({destinationHash.length / 2}B) + + )} + {sourceHash && ( + + From + ({sourceHash.length / 2}B) + + )} + {headerSlot} + {cipherMac && } + {ciphertext && } +
+ + {decrypted ? ( +
+ Decrypted + {children(decrypted)} +
+ ) : ( + + )} +
+ ); +} + +function formatUnixTs(unix: number): string { + return formatTimestamp(unix * 1000); +} + +// per-payload-type renderers + +function AdvertPayload({ payload }: PayloadProps) { + const p = payload; + const name = p.name as string | null | undefined; + const publicKey = p.publicKey as string | undefined; + const signature = p.signature as string | undefined; + const signatureValid = p.signatureValid as boolean | undefined; + const timestamp = p.timestamp as number | undefined; + const latitude = p.latitude as number | null | undefined; + const longitude = p.longitude as number | null | undefined; + const hasLocation = p.hasLocation as boolean | undefined; + const flagsByte = p.flags as number | undefined; + + return ( +
+ {publicKey && ( + + Public Key + {publicKey.slice(0, 16).toUpperCase()}… + ({publicKey.length / 2}B) + + )} + + {timestamp != null && ( + + Timestamp + {formatUnixTs(timestamp)} + (4B LE) + + )} + + {signature && ( + + Signature + {signature.slice(0, 16).toUpperCase()}… + ({signature.length / 2}B) + {signatureValid != null && ( + <> + {" "} + + + )} + + )} + + +
Flags Byte
+
+ Role {String(p.deviceRoleName ?? "?")} + Loc + F1 + F2 + Name +
+ {flagsByte != null && } +
+ + {hasLocation && (latitude != null || longitude != null) && ( + + Location + + {latitude != null ? `${Math.abs(latitude).toFixed(4)}° ${latitude >= 0 ? "N" : "S"}` : "—"} + {", "} + {longitude != null ? `${Math.abs(longitude).toFixed(4)}° ${longitude >= 0 ? "E" : "W"}` : "—"} + + (8B LE) + + )} + + {name && ( + + Name + {name} + ({new TextEncoder().encode(name).length}B UTF-8) + + )} +
+ ); +} + +function TracePayload({ payload }: PayloadProps) { + const traceTag = payload.traceTag as string | undefined; + const authCode = payload.authCode as number | undefined; + const pathHashes = payload.pathHashes as string[] | undefined; + const snrValues = payload.snrValues as number[] | null | undefined; + + return ( +
+
+ {traceTag && ( +
+ Tag + +
+ )} + {authCode != null && ( + + 0x{authCode.toString(16).toUpperCase()} + + )} +
+ + {pathHashes && pathHashes.length > 0 && ( +
+ Trace Path +
+ {pathHashes.map((hash, i) => { + const snr = snrValues?.[i] ?? null; + const level = snrLevel(snr); + const sigClass = level ? SIGNAL_LEVEL_CLASSES[level] : "text-text-normal"; + return ( + + {i > 0 && } + + + {snr != null && ( + {formatSnr(snr)} dB + )} + + + ); + })} +
+
+ )} +
+ ); +} + +function GroupTextPayload({ payload }: PayloadProps) { + const channelHash = payload.channelHash as string | undefined; + const cipherMac = payload.cipherMac as string | undefined; + const ciphertext = payload.ciphertext as string | undefined; + const decrypted = payload.decrypted as Record | null | undefined; + + return ( +
+
+ {channelHash && } + {cipherMac && } + {ciphertext && } +
+ + {decrypted ? ( +
+ Decrypted +
+ {decrypted.sender != null && ( +
+ Sender + {String(decrypted.sender)} +
+ )} + {decrypted.message != null && ( +
+ Message + {String(decrypted.message)} +
+ )} +
+ {decrypted.timestamp != null && {formatUnixTs(decrypted.timestamp as number)}} + {decrypted.flags != null && Flags {String(decrypted.flags)}} +
+
+
+ ) : ( + + )} +
+ ); +} + +function TextPayload({ payload }: PayloadProps) { + return ( + + {(d) => ( +
+ {d.message != null && ( +
{String(d.message)}
+ )} +
+ {d.timestamp != null && {formatUnixTs(d.timestamp as number)}} + {d.attempt != null && Attempt {String(d.attempt)}} + {d.flags != null && Flags {String(d.flags)}} +
+
+ )} +
+ ); +} + +function RequestPayload({ payload }: PayloadProps) { + return ( + + {(d) => ( +
+ {d.requestTypeName != null && ( +
{String(d.requestTypeName)}
+ )} + {d.requestData != null && ( + + )} + {d.timestamp != null && ( + + {formatUnixTs(d.timestamp as number)} + + )} +
+ )} +
+ ); +} + +function ResponsePayload({ payload }: PayloadProps) { + return ( + + {(d) => ( +
+ {d.tag != null && ( + {String(d.tag)} + )} + {d.content != null && ( +
{String(d.content)}
+ )} +
+ )} +
+ ); +} + +function AnonRequestPayload({ payload }: PayloadProps) { + const senderPublicKey = payload.senderPublicKey as string | undefined; + return ( + } + > + {(d) => ( +
+ {d.timestamp != null && ( + + {formatUnixTs(d.timestamp as number)} + + )} + {d.syncTimestamp != null && ( + + {formatUnixTs(d.syncTimestamp as number)} + + )} + {d.password != null && ( + {String(d.password)} + )} +
+ )} +
+ ); +} + +function AckPayload({ payload }: PayloadProps) { + const checksum = payload.checksum as string | undefined; + if (!checksum) return null; + + return ( + + Checksum + + ({checksum.length / 2}B) + + ); +} + +function PathPayload({ payload }: PayloadProps) { + return ( + + {(d) => } + + ); +} + +function PathDecryptedContent({ decrypted }: { decrypted: Record }) { + const pathLength = decrypted.pathLength as number | undefined; + const pathHashSize = decrypted.pathHashSize as number | undefined; + const pathHashes = decrypted.pathHashes as string[] | undefined; + const extraTypeName = decrypted.extraTypeName as string | undefined; + const extraData = decrypted.extraData as string | undefined; + + const pathLenByte = pathLength != null && pathHashSize != null + ? ((pathHashSize - 1) << 6) | (pathLength & 0x3f) + : null; + + return ( +
+
+
+ {pathLength != null && ( + {pathLength} + )} + {pathHashSize != null && ( + {pathHashSize}B + )} +
+ {pathLenByte != null && } +
+ + {pathHashes && pathHashes.length > 0 && ( +
+ Path +
+ {pathHashes.map((hash, i) => ( + + {i > 0 && } + + + ))} +
+
+ )} + + {(extraTypeName || extraData) && ( +
+ {extraTypeName && ( +
+ Bundled Type + {extraTypeName} +
+ )} + {extraData && } +
+ )} +
+ ); +} + +function DiscoverReqFields({ payload }: PayloadProps) { + const tag = payload.tag as number | undefined; + const typeFilterNames = payload.typeFilterNames as string[] | undefined; + const since = payload.since as number | null | undefined; + const prefixOnly = payload.prefixOnly as boolean | undefined; + + return ( +
+ {tag != null && ( + + 0x{tag.toString(16).toUpperCase()} + + )} + {typeFilterNames && typeFilterNames.length > 0 && ( +
+ Type Filter + + {typeFilterNames.map((name) => ( + + ))} + +
+ )} + {since != null && ( + + {formatUnixTs(since)} + + )} + {prefixOnly != null && ( + + )} +
+ ); +} + +function DiscoverRespFields({ payload }: PayloadProps) { + const tag = payload.tag as number | undefined; + const nodeTypeName = payload.nodeTypeName as string | undefined; + const snr = payload.snr as number | undefined; + const publicKey = payload.publicKey as string | undefined; + const publicKeyLength = payload.publicKeyLength as number | undefined; + + const level = snr != null ? snrLevel(snr) : null; + const sigClass = level ? SIGNAL_LEVEL_CLASSES[level] : "text-text-normal"; + + return ( +
+ {tag != null && ( + + 0x{tag.toString(16).toUpperCase()} + + )} + {nodeTypeName && ( +
+ Node Type + {nodeTypeName} +
+ )} + {snr != null && ( + {formatSnr(snr)} dB + )} + {publicKey && ( + + )} +
+ ); +} + +function ControlPayload({ payload }: PayloadProps) { + const subTypeName = payload.subTypeName as string | undefined; + + return ( +
+ {subTypeName &&
{subTypeName}
} + {subTypeName === "DISCOVER_REQ" && } + {subTypeName === "DISCOVER_RESP" && } + {subTypeName !== "DISCOVER_REQ" && subTypeName !== "DISCOVER_RESP" && ( + + )} +
+ ); +} + +function RawPayload({ payload }: PayloadProps) { + const raw = payload.raw as string | undefined; + if (!raw) return null; + return ; +} + +// generic key-value renderer for unknown payload types + +function humanizeKey(key: string): string { + return key.replace(/([A-Z])/g, " $1").replace(/^./, (s) => s.toUpperCase()).trim(); +} + +function GenericPayload({ payload }: PayloadProps) { + const hidden = new Set(["type", "ciphertextLength"]); + for (const key of Object.keys(payload)) { + if (payload[`${key}Name`] !== undefined) hidden.add(key); + } + + const isHex = (v: unknown): v is string => typeof v === "string" && /^[0-9a-fA-F]+$/.test(v); + const entries = Object.entries(payload).filter(([key]) => !hidden.has(key)); + + if (entries.length === 0) return null; + + return ( +
+ {entries.map(([key, value]) => { + if (value !== null && typeof value === "object" && !Array.isArray(value)) { + return ( +
+ {humanizeKey(key)} +
+ {Object.entries(value as Record).map(([sk, sv]) => ( +
+ {humanizeKey(sk)} + {String(sv)} +
+ ))} +
+
+ ); + } + + if (Array.isArray(value)) { + return ( +
+ {humanizeKey(key)} + + {(value as unknown[]).map((item, i) => { + if (typeof item === "string" && /^[0-9a-fA-F]+$/.test(item)) { + return ( + + {i > 0 && } + + + ); + } + return {i > 0 ? ", " : ""}{String(item)}; + })} + +
+ ); + } + + if (typeof value === "boolean") { + return ( +
+ {humanizeKey(key)} + +
+ ); + } + + const fieldColor = key in FIELD_COLORS ? FIELD_COLORS[key as FieldId] : null; + if (isHex(value) && fieldColor) { + const display = value.length > 16 ? value.slice(0, 16).toUpperCase() + "…" : value.toUpperCase(); + return ( + + {humanizeKey(key)} + {display} + ({value.length / 2}B) + + ); + } + + if (isHex(value)) { + return ; + } + + if ( + typeof value === "number" && + (key.toLowerCase().includes("timestamp") || key === "since") && + value > 1_000_000_000 && value < 10_000_000_000 + ) { + return ( +
+ {humanizeKey(key)} + {formatUnixTs(value)} +
+ ); + } + + return ( +
+ {humanizeKey(key)} + {String(value)} +
+ ); + })} +
+ ); +} + +// routes payload.type to the right renderer + +export function PayloadBreakdown({ payload }: { payload: Record }) { + switch (payload.type) { + case "ADVERT": return ; + case "TRACE": return ; + case "GROUP_TEXT": return ; + case "TEXT_MESSAGE": return ; + case "REQUEST": return ; + case "RESPONSE": return ; + case "ANON_REQUEST": return ; + case "ACK": return ; + case "PATH": return ; + case "CONTROL": return ; + case "GROUP_DATA": + case "MULTI_PART": + case "CUSTOM": return ; + default: return ; + } +} diff --git a/src/features/packets/types.ts b/src/features/packets/types.ts new file mode 100644 index 0000000..0061c90 --- /dev/null +++ b/src/features/packets/types.ts @@ -0,0 +1,19 @@ +import type { PayloadTypeValue, RouteTypeValue } from "../../types/enums"; + +export type SearchField = "hash" | "path" | "payload"; + +export interface PacketFilterState { + payloadTypes: PayloadTypeValue[]; + routeTypes: RouteTypeValue[]; + observers: string[]; + search: string; + searchField: SearchField; +} + +export const EMPTY_FILTERS: PacketFilterState = { + payloadTypes: [], + routeTypes: [], + observers: [], + search: "", + searchField: "hash", +}; diff --git a/src/features/packets/usePacketFilters.ts b/src/features/packets/usePacketFilters.ts new file mode 100644 index 0000000..af052c9 --- /dev/null +++ b/src/features/packets/usePacketFilters.ts @@ -0,0 +1,140 @@ +import { useSearchParams } from "react-router-dom"; +import { useMemo, useCallback } from "react"; +import type { PacketFilterState, SearchField } from "./types"; +import type { PacketSummary } from "../../types/api"; +import type { PayloadTypeValue, RouteTypeValue } from "../../types/enums"; + +// filter state synced to URL search params + +function parseIntArray(value: string | null): number[] { + if (!value) return []; + return value.split(",").map(Number).filter(Number.isFinite); +} + +function parseStringArray(value: string | null): string[] { + if (!value) return []; + return value.split(",").filter(Boolean); +} + +const VALID_SEARCH_FIELDS = new Set(["hash", "path", "payload"]); + +function parseSearchField(value: string | null): SearchField { + if (value && VALID_SEARCH_FIELDS.has(value as SearchField)) return value as SearchField; + return "hash"; +} + +export function usePacketFilters() { + const [searchParams, setSearchParams] = useSearchParams(); + + const filters: PacketFilterState = useMemo( + () => ({ + payloadTypes: parseIntArray(searchParams.get("types")) as PayloadTypeValue[], + routeTypes: parseIntArray(searchParams.get("routes")) as RouteTypeValue[], + observers: parseStringArray(searchParams.get("obs")), + search: searchParams.get("q") ?? "", + searchField: parseSearchField(searchParams.get("sf")), + }), + [searchParams], + ); + + const setFilter = useCallback( + (key: "payloadTypes" | "routeTypes" | "observers", values: (number | string)[]) => { + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + const paramKey = key === "payloadTypes" ? "types" : key === "routeTypes" ? "routes" : "obs"; + if (values.length === 0) { + next.delete(paramKey); + } else { + next.set(paramKey, values.join(",")); + } + return next; + }, + { replace: true }, + ); + }, + [setSearchParams], + ); + + const setSearch = useCallback( + (query: string) => { + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + if (query) { + next.set("q", query); + } else { + next.delete("q"); + } + return next; + }, + { replace: true }, + ); + }, + [setSearchParams], + ); + + const setSearchField = useCallback( + (field: SearchField) => { + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + if (field === "hash") { + next.delete("sf"); + } else { + next.set("sf", field); + } + return next; + }, + { replace: true }, + ); + }, + [setSearchParams], + ); + + const clearFilters = useCallback(() => { + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + next.delete("types"); + next.delete("routes"); + next.delete("obs"); + next.delete("q"); + next.delete("sf"); + return next; + }, + { replace: true }, + ); + }, [setSearchParams]); + + return { filters, setFilter, setSearch, setSearchField, clearFilters }; +} + +// client-side filter predicate for packet rows + +export function matchesFilters( + packet: PacketSummary, + filters: PacketFilterState, + observersByHash?: ReadonlyMap>, +): boolean { + if (filters.payloadTypes.length > 0 && !filters.payloadTypes.includes(packet.payloadType as PayloadTypeValue)) { + return false; + } + if (filters.routeTypes.length > 0 && !filters.routeTypes.includes(packet.routeType as RouteTypeValue)) { + return false; + } + if (filters.observers.length > 0) { + const known = observersByHash?.get(packet.packetHash); + const match = known + ? filters.observers.some((id) => known.has(id)) + : filters.observers.includes(packet.latestObserver.id); + if (!match) return false; + } + if (filters.search && filters.searchField === "hash") { + const q = filters.search.toLowerCase(); + if (!packet.packetHash.toLowerCase().includes(q)) { + return false; + } + } + return true; +} diff --git a/src/features/packets/usePackets.ts b/src/features/packets/usePackets.ts new file mode 100644 index 0000000..0935f81 --- /dev/null +++ b/src/features/packets/usePackets.ts @@ -0,0 +1,219 @@ +import { useState, useCallback, useMemo, useSyncExternalStore } from "react"; +import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; +import { getPackets } from "../../api/client"; +import { useRegion } from "../../hooks/useRegion"; +import type { WsPacketObservation, WsLagged } from "../../types/ws"; +import type { PacketSummary } from "../../types/api"; +import { ROUTE_TYPE_NAMES, type RouteTypeValue } from "../../types/enums"; +import { LIVE_BUFFER_CAP, MAX_INFINITE_PAGES } from "../../lib/constants"; + +// merge and deduplicate live + paginated packets + +function flattenPages(data: { pages: Array<{ packets: PacketSummary[] }> } | undefined): PacketSummary[] { + if (!data) return []; + return data.pages.flatMap((p) => p.packets); +} + +function dedup(packets: PacketSummary[]): PacketSummary[] { + const seen = new Set(); + return packets.filter((p) => { + if (seen.has(p.packetHash)) return false; + seen.add(p.packetHash); + return true; + }); +} + +interface LiveSnapshot { + buffer: readonly PacketSummary[]; + acknowledgedCount: number; + observersByHash: ReadonlyMap>; +} + +// RAF batching avoids re-rendering on every WS message -- can be 50+/sec during floods + +class LivePacketStore { + private buffer: PacketSummary[] = []; + private hashIndex = new Map(); + private observersByHash = new Map>(); + private acknowledgedCount = 0; + private snapshot: LiveSnapshot = { buffer: [], acknowledgedCount: 0, observersByHash: new Map() }; + private listeners = new Set<() => void>(); + private rafId: number | null = null; + + subscribe = (listener: () => void): (() => void) => { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + }; + + getSnapshot = (): LiveSnapshot => { + return this.snapshot; + }; + + private scheduleNotify(): void { + if (this.rafId !== null) return; + this.rafId = requestAnimationFrame(() => { + this.rafId = null; + this.snapshot = { buffer: this.buffer, acknowledgedCount: this.acknowledgedCount, observersByHash: this.observersByHash }; + for (const l of this.listeners) l(); + }); + } + + pushOrUpdate(summary: PacketSummary): void { + const observers = this.observersByHash.get(summary.packetHash); + if (observers) { + observers.add(summary.latestObserver.id); + } else { + this.observersByHash.set(summary.packetHash, new Set([summary.latestObserver.id])); + } + + const existing = this.hashIndex.get(summary.packetHash); + if (existing !== undefined) { + this.buffer = [...this.buffer]; + this.buffer[existing] = summary; + } else { + this.buffer = [summary, ...this.buffer]; + this.rebuildIndex(); + if (this.buffer.length > LIVE_BUFFER_CAP) { + // TODO: this splice mutates in-place after the spread copy above -- fine for now but sloppy + const removed = this.buffer.splice(LIVE_BUFFER_CAP); + for (const p of removed) { + this.hashIndex.delete(p.packetHash); + this.observersByHash.delete(p.packetHash); + } + } + } + this.scheduleNotify(); + } + + private rebuildIndex(): void { + this.hashIndex.clear(); + for (let i = 0; i < this.buffer.length; i++) { + this.hashIndex.set(this.buffer[i]!.packetHash, i); + } + } + + acknowledge(): void { + this.acknowledgedCount = this.buffer.length; + this.snapshot = { buffer: this.buffer, acknowledgedCount: this.acknowledgedCount, observersByHash: this.observersByHash }; + for (const l of this.listeners) l(); + } + + reset(): void { + if (this.rafId !== null) { + cancelAnimationFrame(this.rafId); + this.rafId = null; + } + this.buffer = []; + this.hashIndex.clear(); + this.observersByHash.clear(); + this.acknowledgedCount = 0; + this.snapshot = { buffer: [], acknowledgedCount: 0, observersByHash: new Map() }; + } +} + +// combines live WS stream with paginated history + +export function usePackets() { + const region = useRegion(); + const queryClient = useQueryClient(); + const [store] = useState(() => new LivePacketStore()); + const [laggedCount, setLaggedCount] = useState(0); + + const [prevRegion, setPrevRegion] = useState(region); + if (prevRegion !== region) { + setPrevRegion(region); + store.reset(); + setLaggedCount(0); + } + + const { buffer: liveBuffer, acknowledgedCount, observersByHash } = useSyncExternalStore( + store.subscribe, + store.getSnapshot, + ); + + const handlePacketObservation = useCallback( + (data: WsPacketObservation["data"]) => { + const summary: PacketSummary = { + packetHash: data.packetHash, + payloadType: data.packet.payloadType, + payloadTypeName: data.packet.payloadTypeName, + routeType: data.packet.routeType, + routeTypeName: ROUTE_TYPE_NAMES[data.packet.routeType as RouteTypeValue] ?? "Unknown", + firstHeardAt: data.observation.heardAt, + lastHeardAt: data.observation.heardAt, + observationCount: data.packet.totalObservationCount, + latestObserver: { + id: data.observation.observerId, + displayName: data.observation.observerName, + iata: data.observation.iata, + }, + summary: data.packet.summary, + }; + + store.pushOrUpdate(summary); + }, + [store], + ); + + const handleLagged = useCallback( + (data: WsLagged) => { + setLaggedCount((prev) => prev + data.droppedCount); + queryClient.invalidateQueries({ queryKey: ["packets", region] }); + }, + [queryClient, region], + ); + + const dismissLagged = useCallback(() => setLaggedCount(0), []); + + const { + data: history, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useInfiniteQuery({ + queryKey: ["packets", region], + queryFn: ({ pageParam }) => getPackets(region, { cursor: pageParam }), + getNextPageParam: (last) => last.nextCursor, + initialPageParam: undefined as string | undefined, + staleTime: Infinity, + maxPages: MAX_INFINITE_PAGES, + }); + + const allPackets = useMemo( + () => dedup([...liveBuffer, ...flattenPages(history)]), + [liveBuffer, history], + ); + + const observerOptions = useMemo(() => { + const map = new Map(); + for (const p of allPackets) { + if (!map.has(p.latestObserver.id)) { + map.set(p.latestObserver.id, p.latestObserver.displayName); + } + } + return Array.from(map.entries()) + .map(([id, name]) => ({ value: id, label: name })) + .sort((a, b) => a.label.localeCompare(b.label)); + }, [allPackets]); + + const newPacketCount = liveBuffer.length - acknowledgedCount; + + const acknowledgeNewPackets = useCallback(() => { + store.acknowledge(); + }, [store]); + + return { + allPackets, + observerOptions, + newPacketCount, + acknowledgeNewPackets, + fetchNextPage, + hasNextPage: hasNextPage ?? false, + isFetchingNextPage, + observersByHash, + handlePacketObservation, + handleLagged, + laggedCount, + dismissLagged, + }; +} diff --git a/src/features/stats/StatsOverview.tsx b/src/features/stats/StatsOverview.tsx new file mode 100644 index 0000000..a342712 --- /dev/null +++ b/src/features/stats/StatsOverview.tsx @@ -0,0 +1,7 @@ +export function StatsOverview() { + return ( +
+ Stats — coming soon +
+ ); +} diff --git a/src/features/stats/types.ts b/src/features/stats/types.ts new file mode 100644 index 0000000..e6857be --- /dev/null +++ b/src/features/stats/types.ts @@ -0,0 +1,12 @@ +export interface PacketVolume { + timestamp: number; + count: number; + byType: Record; +} + +export interface ObserverCoverage { + observerId: string; + observerName: string; + packetCount: number; + uniqueNodes: number; +} diff --git a/src/hooks/useClickOutside.ts b/src/hooks/useClickOutside.ts new file mode 100644 index 0000000..78d9894 --- /dev/null +++ b/src/hooks/useClickOutside.ts @@ -0,0 +1,16 @@ +import { useEffect, type RefObject } from "react"; + +export function useClickOutside( + ref: RefObject, + open: boolean, + onClose: () => void, +): void { + useEffect(() => { + if (!open) return; + function handleClick(e: MouseEvent) { + if (ref.current && !ref.current.contains(e.target as Node)) onClose(); + } + document.addEventListener("mousedown", handleClick); + return () => document.removeEventListener("mousedown", handleClick); + }, [ref, open, onClose]); +} diff --git a/src/hooks/useRegion.tsx b/src/hooks/useRegion.tsx new file mode 100644 index 0000000..4da0882 --- /dev/null +++ b/src/hooks/useRegion.tsx @@ -0,0 +1,29 @@ +/* eslint-disable react-refresh/only-export-components */ +import { createContext, useContext, useState, type ReactNode } from "react"; + +// region context shared across the app + +interface RegionContextValue { + region: string; + setRegion: (region: string) => void; +} + +const RegionContext = createContext(null); + +export function RegionProvider({ defaultRegion, children }: { defaultRegion: string; children: ReactNode }) { + const [region, setRegion] = useState(defaultRegion); + + return {children}; +} + +export function useRegion(): string { + const ctx = useContext(RegionContext); + if (!ctx) throw new Error("useRegion must be used within RegionProvider"); + return ctx.region; +} + +export function useSetRegion(): (region: string) => void { + const ctx = useContext(RegionContext); + if (!ctx) throw new Error("useSetRegion must be used within RegionProvider"); + return ctx.setRegion; +} diff --git a/src/hooks/useTheme.tsx b/src/hooks/useTheme.tsx new file mode 100644 index 0000000..1e41c85 --- /dev/null +++ b/src/hooks/useTheme.tsx @@ -0,0 +1,58 @@ +/* eslint-disable react-refresh/only-export-components */ +import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from "react"; +import { type Theme, DEFAULT_THEME_ID, loadThemes, applyTheme } from "../lib/themes"; + +// loads themes from JSON, persists selection, applies CSS vars + +const STORAGE_KEY = "tower-theme"; + +interface ThemeCtx { + themeId: string; + themes: Theme[]; + setThemeId: (id: string) => void; +} + +const ThemeContext = createContext({ + themeId: DEFAULT_THEME_ID, + themes: [], + setThemeId: () => {}, +}); + +export function ThemeProvider({ children }: { children: ReactNode }) { + const [themes, setThemes] = useState([]); + const [themeId, setThemeIdState] = useState( + () => localStorage.getItem(STORAGE_KEY) ?? DEFAULT_THEME_ID, + ); + + useEffect(() => { + loadThemes().then((loaded) => { + setThemes(loaded); + const saved = localStorage.getItem(STORAGE_KEY); + const match = loaded.find((t) => t.id === saved) ?? loaded[0]; + if (!match) return; + applyTheme(match); + setThemeIdState(match.id); + }); + }, []); + + const setThemeId = useCallback( + (id: string) => { + const match = themes.find((t) => t.id === id); + if (!match) return; + applyTheme(match); + localStorage.setItem(STORAGE_KEY, id); + setThemeIdState(id); + }, + [themes], + ); + + return ( + + {children} + + ); +} + +export function useTheme() { + return useContext(ThemeContext); +} diff --git a/src/hooks/useWsHandlers.ts b/src/hooks/useWsHandlers.ts new file mode 100644 index 0000000..b9e0c78 --- /dev/null +++ b/src/hooks/useWsHandlers.ts @@ -0,0 +1,21 @@ +import { useEffect } from "react"; +import type { WsManager } from "../api/ws-manager"; +import type { WsPacketObservation, WsLagged } from "../types/ws"; + +export function useWsPacketHandler( + manager: WsManager, + handler: (data: WsPacketObservation["data"]) => void, +): void { + useEffect(() => { + return manager.onPacketObservation(handler); + }, [manager, handler]); +} + +export function useWsLaggedHandler( + manager: WsManager, + handler: (data: WsLagged) => void, +): void { + useEffect(() => { + return manager.onLagged(handler); + }, [manager, handler]); +} diff --git a/src/hooks/useWsStatus.ts b/src/hooks/useWsStatus.ts new file mode 100644 index 0000000..2db04f9 --- /dev/null +++ b/src/hooks/useWsStatus.ts @@ -0,0 +1,11 @@ +import { useSyncExternalStore } from "react"; +import type { WsManager, WsStatus } from "../api/ws-manager"; + +export function useWsStatus(manager: WsManager): { status: WsStatus } { + const status = useSyncExternalStore( + (cb) => manager.onStatusChange(cb), + () => manager.getStatus(), + ); + + return { status }; +} diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..dfdf172 --- /dev/null +++ b/src/index.css @@ -0,0 +1,55 @@ +@import "tailwindcss"; + +/* theme tokens from palette CSS vars */ +@theme { + --font-mono: "JetBrains Mono", monospace; + --font-sans: "Inter", system-ui, sans-serif; + + --color-bg-base: var(--palette-bg-base, #09090B); + --color-bg-surface: var(--palette-bg-surface, #111114); + --color-bg-raised: var(--palette-bg-raised, #1A1A1F); + --color-border: var(--palette-border, #27272A); + --color-border-subtle: var(--palette-border-subtle, #1E1E22); + + --color-primary: var(--palette-primary, #3B82F6); + --color-primary-dim: var(--palette-primary-dim, #1D4ED8); + --color-secondary: var(--palette-secondary, #A78BFA); + --color-green: var(--palette-green, #22C55E); + --color-danger: var(--palette-danger, #EF4444); + --color-warn: var(--palette-warn, #EAB308); + + --color-text-bright: var(--palette-text-bright, #FAFAFA); + --color-text-normal: var(--palette-text-normal, #A1A1AA); + --color-text-muted: var(--palette-text-muted, #73737B); + --color-text-dim: var(--palette-text-dim, #5F5F65); +} + +/* base resets */ +@layer base { + html, body, #root { + @apply h-full; + } + body { + @apply bg-bg-base text-text-normal font-sans text-[13px] leading-relaxed m-0; + } + + * { + scrollbar-width: thin; + scrollbar-color: var(--color-border) transparent; + } +} + +/* animations and utilities */ +@keyframes packet-pulse { + 0% { border-color: var(--color-secondary); box-shadow: 0 0 12px color-mix(in srgb, var(--color-secondary) 15%, transparent); } + 100% { border-color: var(--color-border); box-shadow: none; } +} + +.packet-fresh { + animation: packet-pulse 1s ease-out forwards; +} + +.writing-vertical { + writing-mode: vertical-rl; + text-orientation: mixed; +} diff --git a/src/lib/constants.ts b/src/lib/constants.ts new file mode 100644 index 0000000..f262a76 --- /dev/null +++ b/src/lib/constants.ts @@ -0,0 +1,14 @@ +export const API_BASE = import.meta.env.VITE_API_BASE ?? "http://localhost:8080/api/v1"; +export const WS_URL = import.meta.env.VITE_WS_URL ?? "ws://localhost:8080/ws"; + +export const LIVE_BUFFER_CAP = 500; +export const MAX_INFINITE_PAGES = 20; +export const DEFAULT_PAGE_SIZE = 50; + +export const SCROLL_TOP_THRESHOLD_PX = 100; +export const SCROLL_BOTTOM_THRESHOLD_PX = 500; + +export const WS_PING_INTERVAL_MS = 30_000; +export const WS_RECONNECT_BASE_MS = 1000; +export const WS_RECONNECT_MAX_MS = 30_000; +export const WS_RECONNECT_JITTER = 0.25; diff --git a/src/lib/formatters.ts b/src/lib/formatters.ts new file mode 100644 index 0000000..25583e9 --- /dev/null +++ b/src/lib/formatters.ts @@ -0,0 +1,50 @@ +// hex and time display helpers + +export function formatHex(hex: string): string { + return hex.slice(0, 8).toUpperCase(); +} + +export function formatTimestamp(epochMs: number): string { + // 12h because most users are US-based, might want to make this a setting + return new Date(epochMs).toLocaleTimeString(undefined, { + hour: "numeric", + minute: "2-digit", + hour12: true, + }); +} + +export function formatTimeOnly(epochMs: number): string { + return new Date(epochMs).toLocaleTimeString(undefined, { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }); +} + +// signal quality and radio metric formatting + +export type SignalLevel = "good" | "mid" | "bad"; + +export const SIGNAL_LEVEL_CLASSES: Record = { + good: "text-green", + mid: "text-warn", + bad: "text-danger", +}; + +export function snrLevel(snr: number | null): SignalLevel | null { + if (snr === null) return null; + if (snr >= 10) return "good"; + if (snr >= 5) return "mid"; + return "bad"; +} + +export function formatSnr(snr: number | null): string { + if (snr === null) return "—"; + return snr.toFixed(2); +} + +export function formatPropagation(ms: number | null): string { + if (ms === null) return "—"; + return `${(ms / 1000).toFixed(3)}s`; +} diff --git a/src/lib/themes.ts b/src/lib/themes.ts new file mode 100644 index 0000000..8825d9c --- /dev/null +++ b/src/lib/themes.ts @@ -0,0 +1,49 @@ +// theme loading and CSS var injection + +export interface Theme { + id: string; + name: string; + vars: Record; +} + +export const DEFAULT_THEME_ID = "neutral-blue"; + +const FALLBACK: Theme = { + id: "neutral-blue", + name: "Neutral Blue", + vars: { + "--palette-bg-base": "#09090B", + "--palette-bg-surface": "#111114", + "--palette-bg-raised": "#1A1A1F", + "--palette-border": "#27272A", + "--palette-border-subtle": "#1E1E22", + "--palette-primary": "#3B82F6", + "--palette-primary-dim": "#1D4ED8", + "--palette-secondary": "#A78BFA", + "--palette-green": "#22C55E", + "--palette-danger": "#EF4444", + "--palette-warn": "#EAB308", + "--palette-text-bright": "#FAFAFA", + "--palette-text-normal": "#A1A1AA", + "--palette-text-muted": "#73737B", + "--palette-text-dim": "#5F5F65", + }, +}; + +export async function loadThemes(): Promise { + try { + const res = await fetch(`${import.meta.env.BASE_URL}themes.json`); + if (!res.ok) return [FALLBACK]; + const data: Theme[] = await res.json(); + return data.length > 0 ? data : [FALLBACK]; + } catch { + return [FALLBACK]; + } +} + +export function applyTheme(theme: Theme) { + const root = document.documentElement; + for (const [prop, value] of Object.entries(theme.vars)) { + root.style.setProperty(prop, value); + } +} diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..a93d054 --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { App } from "./App"; +import "./index.css"; + +createRoot(document.getElementById("root")!).render( + + + +); diff --git a/src/types/api.ts b/src/types/api.ts new file mode 100644 index 0000000..b1c6261 --- /dev/null +++ b/src/types/api.ts @@ -0,0 +1,97 @@ +import type { PathConfidence } from "./enums"; + +// response wrappers + +export interface CursorPage { + packets: T[]; + nextCursor: string | null; +} + +export interface LatestObserver { + id: string; + displayName: string; + iata: string; +} + +// packet list and detail shapes + +export interface PacketSummary { + packetHash: string; + payloadType: number; + payloadTypeName: string; + routeType: number; + routeTypeName: string; + firstHeardAt: number; + lastHeardAt: number; + observationCount: number; + latestObserver: LatestObserver; + summary: string; +} + +export interface ResolvedHop { + confidence: PathConfidence; + node?: { + id: string; + name: string; + publicKey: string; + latitude: number | null; + longitude: number | null; + }; + candidates?: Array<{ + id: string; + name: string; + publicKey: string; + }>; + idBytes?: string; +} + +export interface Observation { + id: number; + observerId: string; + observerName: string; + iata: string; + heardAt: number; + pathLengthByte: number; + hashSize: number; + hopCount: number; + pathBytes: string; + rawPacket: string; + rssi: number | null; + snr: number | null; + propagationTimeMs: number | null; + radio: { + freqMhz: number; + spreadFactor: number; + bandwidthKhz: number; + codingRate: number; + } | null; + sourceBroker: string; + resolvedPath: ResolvedHop[]; +} + +export interface PacketDetail { + packetHash: string; + headerByte: string; + payloadType: number; + payloadVersion: number; + routeType: number; + transportCodes: { regionCode: number; subRegionCode: number } | null; + originPubkey: string | null; + parsedPayload: Record | null; + rawPayload: string; + decrypted: boolean; + channelHash: string | null; + summary: string; + firstHeardAt: number; + lastHeardAt: number; + observations: Observation[]; +} + +// region metadata + +export interface IataCode { + iata: string; + displayName: string | null; + approxLat: number | null; + approxLng: number | null; +} diff --git a/src/types/enums.ts b/src/types/enums.ts new file mode 100644 index 0000000..9bac3a8 --- /dev/null +++ b/src/types/enums.ts @@ -0,0 +1,53 @@ +// MeshCore packet payload types (header bits 2-5) + +export const PayloadType = { + REQUEST: 0x00, + RESPONSE: 0x01, + TEXT: 0x02, + ACK: 0x03, + ADVERT: 0x04, + GROUP_TEXT: 0x05, + GROUP_DATA: 0x06, + ANON_REQ: 0x07, + PATH: 0x08, + TRACE: 0x09, + MULTI_PART: 0x0a, + CUSTOM: 0x0f, +} as const; + +export type PayloadTypeValue = (typeof PayloadType)[keyof typeof PayloadType]; + +export const PAYLOAD_TYPE_NAMES: Record = { + [PayloadType.REQUEST]: "Request", + [PayloadType.RESPONSE]: "Response", + [PayloadType.TEXT]: "Text", + [PayloadType.ACK]: "Ack", + [PayloadType.ADVERT]: "Advert", + [PayloadType.GROUP_TEXT]: "Group", + [PayloadType.GROUP_DATA]: "GroupData", + [PayloadType.ANON_REQ]: "AnonReq", + [PayloadType.PATH]: "Path", + [PayloadType.TRACE]: "Trace", + [PayloadType.MULTI_PART]: "MultiPart", + [PayloadType.CUSTOM]: "Custom", +}; + +// routing modes (header bits 1-0) and path confidence + +export const RouteType = { + TRANSPORT_FLOOD: 0, + FLOOD: 1, + DIRECT: 2, + TRANSPORT_DIRECT: 3, +} as const; + +export type RouteTypeValue = (typeof RouteType)[keyof typeof RouteType]; + +export const ROUTE_TYPE_NAMES: Record = { + [RouteType.TRANSPORT_FLOOD]: "Flood Transport", + [RouteType.FLOOD]: "Flood", + [RouteType.DIRECT]: "Direct", + [RouteType.TRANSPORT_DIRECT]: "Direct Transport", +}; + +export type PathConfidence = "high" | "ambiguous" | "none"; diff --git a/src/types/ws.ts b/src/types/ws.ts new file mode 100644 index 0000000..e7e2b26 --- /dev/null +++ b/src/types/ws.ts @@ -0,0 +1,119 @@ +import type { Observation } from "./api"; + +// individual server-sent message shapes + +export interface WsHello { + v: 1; + type: "hello"; + serverTime: number; + connectionId: string; +} + +export interface WsSubscribed { + v: 1; + type: "subscribed"; + id: string; + subscriptionId: string; +} + +export interface WsUnsubscribed { + v: 1; + type: "unsubscribed"; + id: string; +} + +export interface WsPong { + v: 1; + type: "pong"; + id: string; +} + +export interface WsPacketObservation { + v: 1; + type: "event"; + event: "packetObservation"; + data: { + packetHash: string; + packet: { + payloadType: number; + payloadTypeName: string; + routeType: number; + isFirstObservation: boolean; + totalObservationCount: number; + summary: string; + }; + observation: Observation; + }; +} + +export interface WsObserverStatus { + v: 1; + type: "event"; + event: "observerStatus"; + data: { + observerId: string; + displayName: string; + iata: string; + online: boolean; + batteryMv: number | null; + uptimeSeconds: number | null; + lastStatusAt: number; + fields: string[]; + }; +} + +export interface WsNodeUpdate { + v: 1; + type: "event"; + event: "nodeUpdate"; + data: { + nodeId: string; + publicKey: string; + name: string | null; + supportsMultibytePaths: boolean; + supportsMultibyteTraces: boolean; + minFirmwareVersion: string | null; + iatasHeardIn: string[]; + reason: "advertRefresh" | "capabilityUpgraded" | "newIataHeard"; + }; +} + +export interface WsLagged { + v: 1; + type: "lagged"; + droppedCount: number; + since: number; + lastObservationId: number; +} + +export interface WsError { + v: 1; + type: "error"; + code: string; + message: string; +} + +// discriminated union of all server messages + +export type WsServerMessage = + | WsHello + | WsSubscribed + | WsUnsubscribed + | WsPong + | WsPacketObservation + | WsObserverStatus + | WsNodeUpdate + | WsLagged + | WsError; + +// client-sent subscription filter + +export interface SubscriptionFilter { + iatas?: string[]; + regionIds?: string[]; + payloadTypes?: number[]; + routeTypes?: string[]; + channelHashes?: string[]; + observerIds?: string[]; + events?: string[]; +} diff --git a/tests/api/ws-manager.test.ts b/tests/api/ws-manager.test.ts new file mode 100644 index 0000000..5bc1616 --- /dev/null +++ b/tests/api/ws-manager.test.ts @@ -0,0 +1,186 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { WsManager } from "../../src/api/ws-manager"; + +class MockWebSocket { + static instances: MockWebSocket[] = []; + static OPEN = 1; + static CLOSED = 3; + static CONNECTING = 0; + static CLOSING = 2; + readyState = 0; + onopen: (() => void) | null = null; + onclose: ((e: { code: number }) => void) | null = null; + onmessage: ((e: { data: string }) => void) | null = null; + onerror: (() => void) | null = null; + sent: string[] = []; + + constructor(public url: string) { + MockWebSocket.instances.push(this); + } + + send(data: string) { + this.sent.push(data); + } + + close() { + this.readyState = 3; + this.onclose?.({ code: 1000 }); + } + + simulateOpen() { + this.readyState = 1; + this.onopen?.(); + } + + simulateMessage(data: unknown) { + this.onmessage?.({ data: JSON.stringify(data) }); + } + + simulateClose(code = 1006) { + this.readyState = 3; + this.onclose?.({ code }); + } +} + +beforeEach(() => { + MockWebSocket.instances = []; + vi.stubGlobal("WebSocket", MockWebSocket); + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe("WsManager", () => { + it("connects and sends subscribe on hello", () => { + const mgr = new WsManager("ws://test/ws"); + mgr.connect({ iatas: ["YOW"] }); + + const ws = MockWebSocket.instances[0]!; + ws.simulateOpen(); + ws.simulateMessage({ v: 1, type: "hello", serverTime: 123, connectionId: "abc" }); + + expect(ws.sent).toHaveLength(1); + const sub = JSON.parse(ws.sent[0]!); + expect(sub.type).toBe("subscribe"); + expect(sub.scope.iatas).toEqual(["YOW"]); + }); + + it("exposes connected status after hello", () => { + const mgr = new WsManager("ws://test/ws"); + expect(mgr.getStatus()).toBe("disconnected"); + + mgr.connect({ iatas: ["YOW"] }); + expect(mgr.getStatus()).toBe("connecting"); + + const ws = MockWebSocket.instances[0]!; + ws.simulateOpen(); + ws.simulateMessage({ v: 1, type: "hello", serverTime: 123, connectionId: "abc" }); + expect(mgr.getStatus()).toBe("connected"); + }); + + it("calls packet handler on packetObservation event", () => { + const handler = vi.fn(); + const mgr = new WsManager("ws://test/ws"); + mgr.onPacketObservation(handler); + mgr.connect({ iatas: ["YOW"] }); + + const ws = MockWebSocket.instances[0]!; + ws.simulateOpen(); + ws.simulateMessage({ v: 1, type: "hello", serverTime: 123, connectionId: "abc" }); + ws.simulateMessage({ + v: 1, + type: "event", + event: "packetObservation", + data: { packetHash: "abc123", packet: {}, observation: {} }, + }); + + expect(handler).toHaveBeenCalledOnce(); + expect(handler.mock.calls[0]![0].packetHash).toBe("abc123"); + }); + + it("reconnects with jittered backoff on unexpected close", () => { + const mgr = new WsManager("ws://test/ws"); + mgr.connect({ iatas: ["YOW"] }); + + const ws = MockWebSocket.instances[0]!; + ws.simulateOpen(); + ws.simulateMessage({ v: 1, type: "hello", serverTime: 123, connectionId: "abc" }); + ws.simulateClose(1006); + + expect(mgr.getStatus()).toBe("connecting"); + expect(MockWebSocket.instances).toHaveLength(1); + + vi.advanceTimersByTime(1500); + expect(MockWebSocket.instances).toHaveLength(2); + }); + + it("resubscribes on reconnect", () => { + const mgr = new WsManager("ws://test/ws"); + mgr.connect({ iatas: ["YOW"] }); + + const ws1 = MockWebSocket.instances[0]!; + ws1.simulateOpen(); + ws1.simulateMessage({ v: 1, type: "hello", serverTime: 123, connectionId: "abc" }); + ws1.simulateClose(1006); + + vi.advanceTimersByTime(1500); + const ws2 = MockWebSocket.instances[1]!; + ws2.simulateOpen(); + ws2.simulateMessage({ v: 1, type: "hello", serverTime: 456, connectionId: "def" }); + + const sub = JSON.parse(ws2.sent[0]!); + expect(sub.type).toBe("subscribe"); + expect(sub.scope.iatas).toEqual(["YOW"]); + }); + + it("updates subscription without reconnecting", () => { + const mgr = new WsManager("ws://test/ws"); + mgr.connect({ iatas: ["YOW"] }); + + const ws = MockWebSocket.instances[0]!; + ws.simulateOpen(); + ws.simulateMessage({ v: 1, type: "hello", serverTime: 123, connectionId: "abc" }); + ws.simulateMessage({ v: 1, type: "subscribed", id: "sub-1", subscriptionId: "s-1" }); + + mgr.updateSubscription({ iatas: ["SEA"] }); + + expect(MockWebSocket.instances).toHaveLength(1); + const unsub = JSON.parse(ws.sent[1]!); + expect(unsub.type).toBe("unsubscribe"); + const newSub = JSON.parse(ws.sent[2]!); + expect(newSub.scope.iatas).toEqual(["SEA"]); + }); + + it("fires status listeners on state changes", () => { + const listener = vi.fn(); + const mgr = new WsManager("ws://test/ws"); + mgr.onStatusChange(listener); + mgr.connect({ iatas: ["YOW"] }); + + expect(listener).toHaveBeenCalledWith("connecting"); + + const ws = MockWebSocket.instances[0]!; + ws.simulateOpen(); + ws.simulateMessage({ v: 1, type: "hello", serverTime: 123, connectionId: "abc" }); + + expect(listener).toHaveBeenCalledWith("connected"); + }); + + it("calls lagged handler on lagged message", () => { + const handler = vi.fn(); + const mgr = new WsManager("ws://test/ws"); + mgr.onLagged(handler); + mgr.connect({ iatas: ["YOW"] }); + + const ws = MockWebSocket.instances[0]!; + ws.simulateOpen(); + ws.simulateMessage({ v: 1, type: "hello", serverTime: 123, connectionId: "abc" }); + ws.simulateMessage({ v: 1, type: "lagged", droppedCount: 47, since: 100, lastObservationId: 12340 }); + + expect(handler).toHaveBeenCalledOnce(); + expect(handler.mock.calls[0]![0].droppedCount).toBe(47); + }); +}); diff --git a/tests/lib/formatters.test.ts b/tests/lib/formatters.test.ts new file mode 100644 index 0000000..0a60671 --- /dev/null +++ b/tests/lib/formatters.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from "vitest"; +import { + formatHex, + formatTimestamp, + formatTimeOnly, + formatSnr, + snrLevel, + formatPropagation, +} from "../../src/lib/formatters"; + +describe("formatHex", () => { + it("truncates to 8 chars uppercase", () => { + expect(formatHex("9e9b7d6a91cab445")).toBe("9E9B7D6A"); + }); + + it("handles short hashes", () => { + expect(formatHex("abcd")).toBe("ABCD"); + }); +}); + +describe("formatTimestamp", () => { + it("formats epoch ms to locale time string", () => { + const result = formatTimestamp(1747665456000); + expect(result).toMatch(/\d{1,2}:\d{2}\s?(a\.?m\.?|p\.?m\.?)/i); + }); +}); + +describe("formatTimeOnly", () => { + it("formats epoch ms to HH:MM:SS", () => { + const result = formatTimeOnly(1747665456000); + expect(result).toMatch(/\d{2}:\d{2}:\d{2}/); + }); +}); + +describe("snrLevel", () => { + it("returns good for SNR >= 10", () => { + expect(snrLevel(10.5)).toBe("good"); + }); + + it("returns mid for SNR between 5 and 10", () => { + expect(snrLevel(7.2)).toBe("mid"); + }); + + it("returns bad for SNR < 5", () => { + expect(snrLevel(3.1)).toBe("bad"); + }); + + it("returns null for null input", () => { + expect(snrLevel(null)).toBeNull(); + }); +}); + +describe("formatSnr", () => { + it("formats to 2 decimal places", () => { + expect(formatSnr(10.756)).toBe("10.76"); + }); + + it("returns dash for null", () => { + expect(formatSnr(null)).toBe("—"); + }); +}); + +describe("formatPropagation", () => { + it("formats ms to seconds string", () => { + expect(formatPropagation(1936)).toBe("1.936s"); + }); + + it("returns dash for null", () => { + expect(formatPropagation(null)).toBe("—"); + }); +}); diff --git a/tests/setup.ts b/tests/setup.ts new file mode 100644 index 0000000..e262193 --- /dev/null +++ b/tests/setup.ts @@ -0,0 +1,7 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup } from "@testing-library/react"; +import { afterEach } from "vitest"; + +afterEach(() => { + cleanup(); +}); diff --git a/tsconfig.app.json b/tsconfig.app.json new file mode 100644 index 0000000..b1b53ab --- /dev/null +++ b/tsconfig.app.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "module": "esnext", + "types": ["vite/client"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Strict mode */ + "strict": true, + "noUncheckedIndexedAccess": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + + /* Path aliases */ + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..c9d60a1 --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "module": "esnext", + "types": ["node", "vitest/globals"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..c63978e --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,12 @@ +/// +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import tailwindcss from "@tailwindcss/vite"; + +export default defineConfig({ + plugins: [react(), tailwindcss()], + test: { + globals: true, + environment: "jsdom", + }, +});