diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 452b79d..78d44a3 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -16,3 +16,21 @@ jobs:
python-version: "3.12"
- run: pip install -r requirements-dev.txt
- run: python -m pytest -v
+ web:
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ defaults:
+ run:
+ working-directory: web
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: "22"
+ cache: npm
+ cache-dependency-path: web/package-lock.json
+ - run: npm ci
+ - run: npm run lint
+ - run: npm run typecheck
+ - run: npm test
+ - run: npm run build
diff --git a/.gitignore b/.gitignore
index b85754f..cbbadad 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,3 +14,8 @@ __pycache__/
# OS
.DS_Store
Thumbs.db
+
+# New frontend
+web/node_modules/
+web/dist/
+public/
diff --git a/.vercelignore b/.vercelignore
index 92e19bd..abd74c1 100644
--- a/.vercelignore
+++ b/.vercelignore
@@ -5,3 +5,4 @@ __pycache__/
*.pyo
.env
.git
+web/node_modules
diff --git a/CLAUDE.md b/CLAUDE.md
index 8a73f3b..a1a4b5c 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -15,6 +15,8 @@ health-dashboard/
|- parse_health.py # Local-only Apple Health XML parser
|- vercel.json # SPA rewrites for Vercel
|- .env # Local env vars (gitignored)
+|- web/ # New frontend (Vite + React + TS), served at /app/
+|- scripts/ # build-site.mjs, make_demo_seed.py
|- supabase/ # migrations/, rollback/, tests/ (rls_check.sql), seed_demo.sql
`- api/
|- health.py # GET /api/health
@@ -107,7 +109,7 @@ The frontend and chat prompt expect this structure:
}
```
-`daily` is a dictionary keyed by date string, not an array.
+`daily` is a dictionary keyed by date string, not an array. Metrics and summary stats can be `null` (days or datasets with no data), and daily entries may also include `heart_rate_avg`, `heart_rate_min` and `heart_rate_max`.
---
@@ -168,6 +170,7 @@ Returns `{ url, anonKey }` for frontend initialization.
## Frontend Notes
- `index.html` is the whole app.
+- The new app in `web/` fetches its Supabase config at runtime from `/api/supabase_config` and sends the user's JWT as `Authorization: Bearer` on `/api/*` calls. `AuthStatus` is `unconfigured` in local dev (no Supabase env), which skips login. `index.html` (legacy) is still the production app at `/`.
- `chatConfig` tracks which server-side providers are configured and which models they use.
- `currentChatMode` is `server`, `claude`, or `byok`.
- The Insights page has a **Vertex / Claude / BYOK** 3-button model selector. Vertex and Claude are always clickable (server keys, free for users). BYOK is greyed out until the user saves a key.
diff --git a/README.md b/README.md
index f4a4cb3..5bd7cb5 100644
--- a/README.md
+++ b/README.md
@@ -107,6 +107,28 @@ Open `http://localhost:8080`.
---
+## New app (work in progress)
+
+A rebuilt frontend lives in `web/` (Vite + React + TypeScript) and is served at `/app/` next to the classic page, which stays at `/` until the new app reaches parity. Today it has sign-in (email/password and Google), an app shell, and a dashboard placeholder; charts, upload and chat are still in the classic app.
+
+Develop it with two terminals:
+
+```bash
+vercel dev # serves the Python API on :3000 (needs SUPABASE_* env vars)
+npm --prefix web install
+npm --prefix web run dev # http://localhost:5173/app/ (proxies /api to :3000)
+```
+
+Checks: `npm --prefix web run lint`, `typecheck`, `test`, `build`. The Vercel build assembles both apps with `scripts/build-site.mjs`.
+
+For Google sign-in on the new app, add `https:///app/` (and any preview URLs) to the Redirect URLs in Supabase Authentication settings.
+
+Open it with the trailing slash (`/app/`).
+
+If the classic page is missing at `http://localhost:3000/` under `vercel dev`, build it first with `npm --prefix web run build && node scripts/build-site.mjs`.
+
+---
+
## Features
- Dashboard views for steps, sleep, resting HR, and HRV
diff --git a/docs/superpowers/plans/2026-10-03-step1-web-scaffold.md b/docs/superpowers/plans/2026-10-03-step1-web-scaffold.md
new file mode 100644
index 0000000..424339d
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-03-step1-web-scaffold.md
@@ -0,0 +1,1797 @@
+# Step 1: New Frontend Scaffold (`web/`) with Auth and Shell — Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Stand up the Vite + React + TypeScript app in `web/`, served at `/app/` next to the untouched legacy page at `/`, with working Supabase sign-in (email/password and Google), a persistent app shell (Dashboard / Insights / Settings), and a dashboard placeholder that proves the authenticated `/api/health` round trip including the demo-data banner.
+
+**Architecture:** A typed API client attaches the user's Supabase JWT to `/api/*` calls. A `AuthProvider` loads the Supabase client at runtime from `/api/supabase_config` (so no build-time env vars) and exposes status/session/actions through context. React Router (basename `/app`) guards routes with a `ProtectedRoute`; TanStack Query fetches health data. A tiny Node script assembles `public/` (legacy page at `/`, built SPA at `/app/`) for Vercel; the Python API is untouched.
+
+**Tech Stack:** Vite, React, TypeScript (strict), React Router (`react-router-dom`), TanStack Query, `@supabase/supabase-js`, Vitest + Testing Library (jsdom), ESLint (flat config, typescript-eslint). Node >= 20.19 (dev machine has 22.18).
+
+**Spec:** `docs/superpowers/specs/2026-10-03-somada-modernization-design.md` (sections "New frontend (`web/`)" and "Rollout" step 2). This is plan 2 of 6; Step 0 (privacy fix) is merged to `master` (PR #1).
+
+## Global Constraints
+
+- Frontend stack per spec: "Vite, React, TypeScript (strict), React Router, TanStack Query, a charting library behind a thin wrapper, Vitest, one Playwright smoke test. Supabase JS from npm." This plan covers everything except the charting library and Playwright (out of scope, see below).
+- The legacy `index.html` and the `/api/*` Python functions keep working unchanged throughout; this plan must not edit `index.html` or anything under `api/`.
+- "`vercel.json` routes `/api/*` to Python and the rest to the SPA, with the legacy page at `/legacy` until cutover." Ruling for Steps 1-4 (needs user confirmation, see Task 8): the legacy page stays at `/` and the new app lives at `/app/`; the swap to `/` happens at cutover (Step 6).
+- TypeScript `strict: true`; no `any`, no `@ts-ignore`.
+- No secrets in the bundle: Supabase URL/anon key are fetched at runtime from `/api/supabase_config`; BYOK keys are not touched in this plan.
+- When `/api/supabase_config` returns no URL/key (local dev), the app runs unauthenticated (status `unconfigured`), matching the API's local-dev behavior.
+- Existing Python CI job (`api`) must keep passing; new CI job `web` runs lint, typecheck, tests and build.
+- Commit messages end with the line `Co-Authored-By: Claude Sonnet 5.5 `. Work on branch `feat/web-scaffold` (default branch is `master`).
+
+**Out of scope (later plans):** XML parsing worker, onboarding/upload flow, charts and the charting library choice (Recharts vs Chart.js), real dashboard views, chat/BYOK UI, life-event editing, Playwright smoke test, rate limiting, cutover to `/`.
+
+## File Structure
+
+```text
+web/
+ package.json, package-lock.json
+ index.html
+ tsconfig.json
+ vite.config.ts # base '/app/', dev proxy /api -> :3000, vitest config
+ eslint.config.js
+ src/
+ main.tsx # providers + render
+ styles.css
+ config.test.ts # guards the /app/ base
+ lib/
+ health/types.ts # HealthData & friends (shape from CLAUDE.md)
+ api/client.ts # request(), getSupabaseConfig/getAppConfig/getHealth, ApiError
+ api/client.test.ts
+ supabase/client.ts # getSupabase(): cached runtime client or null
+ supabase/auth.tsx # AuthContext, AuthProvider, useAuth
+ supabase/auth.test.tsx
+ app/
+ AppRoutes.tsx # route table
+ AppRoutes.test.tsx
+ ProtectedRoute.tsx (+ .test.tsx)
+ Shell.tsx (+ .test.tsx)
+ PlaceholderPage.tsx
+ features/
+ auth/LoginPage.tsx (+ .test.tsx)
+ dashboard/DashboardPage.tsx (+ .test.tsx)
+ test/
+ setup.ts # jest-dom matchers
+ fetch.ts # stubFetch(): records calls, returns canned JSON
+ fixtures.ts # healthFixture()
+ utils.tsx # authValue(), renderAt()
+scripts/build-site.mjs # assembles public/ (legacy at /, SPA at /app/)
+vercel.json, .gitignore, .vercelignore # MODIFY
+.github/workflows/tests.yml # MODIFY: add `web` job
+README.md, CLAUDE.md, spec # MODIFY: docs
+```
+
+---
+
+### Task 1: Toolchain scaffold (Vite, React, TS, ESLint, Vitest)
+
+**Files:**
+- Create: `web/package.json`, `web/tsconfig.json`, `web/vite.config.ts`, `web/eslint.config.js`, `web/index.html`, `web/src/main.tsx`, `web/src/test/setup.ts`, `web/src/config.test.ts`
+- Modify: `.gitignore` (append), `.vercelignore` (append)
+
+**Interfaces:**
+- Produces: working `npm --prefix web run {dev,build,typecheck,lint,test}`; Vitest globals + jsdom + jest-dom matchers; Vite `base: '/app/'` (`import.meta.env.BASE_URL === '/app/'`); dev proxy `/api` -> `http://localhost:3000`.
+
+- [ ] **Step 1: Create the branch**
+
+```bash
+cd /c/Users/meera/Github-Projects/Somada
+git checkout master && git pull origin master && git checkout -b feat/web-scaffold
+mkdir -p web/src/test
+```
+
+- [ ] **Step 2: Write `web/package.json`** (dependencies are added by `npm install` in Step 3)
+
+```json
+{
+ "name": "somada-web",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "engines": { "node": ">=20.19" },
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc --noEmit && vite build",
+ "typecheck": "tsc --noEmit",
+ "lint": "eslint src",
+ "test": "vitest run",
+ "test:watch": "vitest"
+ }
+}
+```
+
+- [ ] **Step 3: Install dependencies** (generates the lockfile; use the latest versions npm resolves)
+
+```bash
+cd web
+npm install react react-dom react-router-dom @tanstack/react-query @supabase/supabase-js
+npm install -D vite @vitejs/plugin-react typescript vitest jsdom \
+ @testing-library/react @testing-library/jest-dom @testing-library/user-event \
+ @types/react @types/react-dom eslint @eslint/js typescript-eslint eslint-plugin-react-hooks
+cd ..
+```
+Expected: `web/node_modules`, `web/package-lock.json`, and `dependencies`/`devDependencies` added to `web/package.json`. If an install fails on peer-dependency conflicts, report it instead of using `--force`.
+
+- [ ] **Step 4: Write the configs**
+
+`web/tsconfig.json`:
+```json
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "jsx": "react-jsx",
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "isolatedModules": true,
+ "skipLibCheck": true,
+ "noEmit": true,
+ "types": ["vite/client", "vitest/globals", "@testing-library/jest-dom"]
+ },
+ "include": ["src", "vite.config.ts"]
+}
+```
+
+`web/vite.config.ts`:
+```ts
+import react from '@vitejs/plugin-react'
+import { defineConfig } from 'vitest/config'
+
+// The SPA is served under /app/ so it can sit next to the legacy page at / until cutover.
+export default defineConfig({
+ base: '/app/',
+ plugins: [react()],
+ server: {
+ // `vercel dev` serves the Python API on :3000
+ proxy: { '/api': 'http://localhost:3000' },
+ },
+ test: {
+ environment: 'jsdom',
+ globals: true,
+ setupFiles: './src/test/setup.ts',
+ },
+})
+```
+
+`web/eslint.config.js`:
+```js
+import js from '@eslint/js'
+import reactHooks from 'eslint-plugin-react-hooks'
+import tseslint from 'typescript-eslint'
+
+export default tseslint.config(
+ { ignores: ['dist'] },
+ js.configs.recommended,
+ ...tseslint.configs.recommended,
+ {
+ plugins: { 'react-hooks': reactHooks },
+ rules: {
+ 'react-hooks/rules-of-hooks': 'error',
+ 'react-hooks/exhaustive-deps': 'warn',
+ },
+ },
+)
+```
+
+`web/index.html`:
+```html
+
+
+
+
+
+
+ Somada
+
+
+
+
+
+
+```
+
+`web/src/main.tsx` (placeholder; replaced in Task 5):
+```tsx
+import { createRoot } from 'react-dom/client'
+
+createRoot(document.getElementById('root')!).render(Somada )
+```
+
+`web/src/test/setup.ts`:
+```ts
+import '@testing-library/jest-dom/vitest'
+```
+
+- [ ] **Step 5: Write the failing guard test** — `web/src/config.test.ts`
+
+```ts
+import { describe, expect, it } from 'vitest'
+
+describe('build config', () => {
+ it('serves the app under /app/ so it can coexist with the legacy page at /', () => {
+ expect(import.meta.env.BASE_URL).toBe('/app/')
+ })
+})
+```
+
+- [ ] **Step 6: Run the toolchain checks**
+
+Run: `npm --prefix web test` -> Expected: 1 passed.
+Run: `npm --prefix web run lint` -> Expected: exit 0, no errors.
+Run: `npm --prefix web run typecheck` -> Expected: exit 0.
+Run: `npm --prefix web run build` -> Expected: `web/dist/index.html` and `web/dist/assets/*.js` exist; script URLs in `web/dist/index.html` start with `/app/assets/`.
+
+If `typecheck` complains about a `types` entry that is not found, remove only that entry from `tsconfig.json` and report it.
+
+- [ ] **Step 7: Ignore build output**
+
+Append to `.gitignore`:
+```text
+
+# New frontend
+web/node_modules/
+web/dist/
+public/
+```
+Append to `.vercelignore`:
+```text
+web/node_modules
+```
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add web .gitignore .vercelignore
+git commit -m "feat(web): scaffold Vite + React + TypeScript app served under /app/"
+```
+
+---
+
+### Task 2: Health types and typed API client
+
+**Files:**
+- Create: `web/src/lib/health/types.ts`, `web/src/lib/api/client.ts`, `web/src/lib/api/client.test.ts`, `web/src/test/fetch.ts`, `web/src/test/fixtures.ts`
+
+**Interfaces:**
+- Produces (`types.ts`): `DailyEntry`, `HealthSummary`, `LifeEvent`, `HealthData` (with optional `is_demo?: boolean`, set by the server for the shared demo row).
+- Produces (`client.ts`):
+ - `type TokenProvider = () => Promise`
+ - `class ApiError extends Error { readonly status: number }` (`name === 'ApiError'`)
+ - `interface SupabaseConfig { url: string; anonKey: string }`, `interface ChatConfig {...}`, `interface AppConfig { chat: ChatConfig }`
+ - `getSupabaseConfig(): Promise` (no auth header)
+ - `getAppConfig(): Promise` (no auth header)
+ - `getHealth(getToken: TokenProvider): Promise` — `null` when the server answers 200 with `{ "error": ... }` ("No health data found."); throws `ApiError` on non-2xx (e.g. 401 `{"error":"Sign in required."}` -> message `Sign in required.`).
+- Produces (test helpers): `stubFetch(status: number, body: unknown): { url: string; headers: Headers }[]` — installs a global `fetch` stub, returns the live array of recorded calls; `healthFixture(overrides?: Partial): HealthData`.
+
+- [ ] **Step 1: Write the types** — `web/src/lib/health/types.ts`
+
+```ts
+// Shape documented in CLAUDE.md ("Health Data Shape"). `daily` is a dictionary keyed by date, not an array.
+export interface DailyEntry {
+ date: string
+ steps: number
+ resting_hr: number
+ hrv: number
+ sleep_hours: number
+ active_calories: number
+ exercise_minutes: number
+ spo2: number
+}
+
+export interface HealthSummary {
+ avg_steps: number
+ avg_sleep_hours: number
+ avg_resting_hr: number
+ avg_hrv: number
+ best_sleep: number
+ worst_sleep: number
+ best_steps_day: string
+ total_days: number
+}
+
+export interface LifeEvent {
+ label: string
+ start: string
+ end: string
+ color: string
+ icon: string
+}
+
+export interface HealthData {
+ daily: Record
+ summary: HealthSummary
+ events: LifeEvent[]
+ generated_at: string
+ /** Set by the server when this is the shared synthetic demo dataset rather than the user's own data. */
+ is_demo?: boolean
+}
+```
+
+- [ ] **Step 2: Write the test helpers**
+
+`web/src/test/fetch.ts`:
+```ts
+import { vi } from 'vitest'
+
+export interface RecordedCall {
+ url: string
+ headers: Headers
+}
+
+/** Replace global fetch with a stub that records calls and answers every request with `body` as JSON. */
+export function stubFetch(status: number, body: unknown): RecordedCall[] {
+ const calls: RecordedCall[] = []
+ vi.stubGlobal('fetch', async (input: RequestInfo | URL, init?: RequestInit) => {
+ calls.push({ url: String(input), headers: new Headers(init?.headers) })
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { 'Content-Type': 'application/json' },
+ })
+ })
+ return calls
+}
+```
+
+`web/src/test/fixtures.ts`:
+```ts
+import type { HealthData } from '../lib/health/types'
+
+export function healthFixture(overrides: Partial = {}): HealthData {
+ return {
+ daily: {
+ '2026-09-30': {
+ date: '2026-09-30',
+ steps: 8000,
+ resting_hr: 61,
+ hrv: 48,
+ sleep_hours: 7.1,
+ active_calories: 420,
+ exercise_minutes: 35,
+ spo2: 97.5,
+ },
+ },
+ summary: {
+ avg_steps: 8000,
+ avg_sleep_hours: 7.1,
+ avg_resting_hr: 61,
+ avg_hrv: 48,
+ best_sleep: 7.1,
+ worst_sleep: 7.1,
+ best_steps_day: '2026-09-30',
+ total_days: 1,
+ },
+ events: [],
+ generated_at: '2026-09-30T00:00:00',
+ ...overrides,
+ }
+}
+```
+
+- [ ] **Step 3: Write the failing tests** — `web/src/lib/api/client.test.ts`
+
+```ts
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { healthFixture } from '../../test/fixtures'
+import { stubFetch } from '../../test/fetch'
+import { ApiError, getAppConfig, getHealth, getSupabaseConfig } from './client'
+
+afterEach(() => {
+ vi.unstubAllGlobals()
+})
+
+describe('getHealth', () => {
+ it('sends the access token as a Bearer header', async () => {
+ const calls = stubFetch(200, healthFixture())
+ await getHealth(async () => 'tok-a')
+ expect(calls).toHaveLength(1)
+ expect(calls[0].url).toBe('/api/health')
+ expect(calls[0].headers.get('Authorization')).toBe('Bearer tok-a')
+ })
+
+ it('omits Authorization when there is no token (unconfigured local dev)', async () => {
+ const calls = stubFetch(200, healthFixture())
+ await getHealth(async () => null)
+ expect(calls[0].headers.has('Authorization')).toBe(false)
+ })
+
+ it('returns the data, including the server-set is_demo marker', async () => {
+ stubFetch(200, healthFixture({ is_demo: true }))
+ const data = await getHealth(async () => 'tok-a')
+ expect(data?.is_demo).toBe(true)
+ expect(data?.summary.total_days).toBe(1)
+ })
+
+ it('returns null when the server answers 200 with an error body (no data yet)', async () => {
+ stubFetch(200, { error: 'No health data found.' })
+ expect(await getHealth(async () => 'tok-a')).toBeNull()
+ })
+
+ it('throws ApiError with the server message on 401', async () => {
+ stubFetch(401, { error: 'Sign in required.' })
+ await expect(getHealth(async () => null)).rejects.toMatchObject({
+ name: 'ApiError',
+ status: 401,
+ message: 'Sign in required.',
+ })
+ })
+
+ it('falls back to a generic message when the error body has no message', async () => {
+ stubFetch(500, null)
+ await expect(getHealth(async () => 'tok-a')).rejects.toBeInstanceOf(ApiError)
+ await expect(getHealth(async () => 'tok-a')).rejects.toMatchObject({
+ message: 'Request failed (500)',
+ })
+ })
+
+ it('treats an empty 200 body as an error instead of crashing', async () => {
+ stubFetch(200, null)
+ await expect(getHealth(async () => 'tok-a')).rejects.toMatchObject({ status: 200 })
+ })
+})
+
+describe('public config endpoints', () => {
+ it('getSupabaseConfig calls /api/supabase_config without credentials', async () => {
+ const calls = stubFetch(200, { url: 'https://x.supabase.co', anonKey: 'k' })
+ const cfg = await getSupabaseConfig()
+ expect(cfg).toEqual({ url: 'https://x.supabase.co', anonKey: 'k' })
+ expect(calls[0].url).toBe('/api/supabase_config')
+ expect(calls[0].headers.has('Authorization')).toBe(false)
+ })
+
+ it('getAppConfig calls /api/config', async () => {
+ const calls = stubFetch(200, { chat: { byokSupported: true } })
+ await getAppConfig()
+ expect(calls[0].url).toBe('/api/config')
+ })
+})
+```
+
+- [ ] **Step 4: Run to verify failure**
+
+Run: `npm --prefix web test -- src/lib/api/client.test.ts`
+Expected: FAIL, cannot resolve `./client`.
+
+- [ ] **Step 5: Implement** — `web/src/lib/api/client.ts`
+
+```ts
+import type { HealthData } from '../health/types'
+
+export type TokenProvider = () => Promise
+
+export class ApiError extends Error {
+ readonly status: number
+
+ constructor(status: number, message: string) {
+ super(message)
+ this.name = 'ApiError'
+ this.status = status
+ }
+}
+
+export interface SupabaseConfig {
+ url: string
+ anonKey: string
+}
+
+export interface ChatConfig {
+ serverVertex: boolean
+ serverModel: string
+ serverClaude: boolean
+ claudeModel: string
+ byokSupported: boolean
+}
+
+export interface AppConfig {
+ chat: ChatConfig
+}
+
+function errorMessage(body: unknown, status: number): string {
+ if (typeof body === 'object' && body !== null && 'error' in body) {
+ const { error } = body as { error: unknown }
+ if (typeof error === 'string') return error
+ }
+ return `Request failed (${status})`
+}
+
+async function request(path: string, getToken: TokenProvider | null): Promise {
+ const headers = new Headers()
+ if (getToken) {
+ const token = await getToken()
+ if (token) headers.set('Authorization', `Bearer ${token}`)
+ }
+ const res = await fetch(path, { headers })
+ const body: unknown = await res.json().catch(() => null)
+ if (!res.ok) throw new ApiError(res.status, errorMessage(body, res.status))
+ if (body === null) throw new ApiError(res.status, 'Unexpected empty response')
+ return body as T
+}
+
+export function getSupabaseConfig(): Promise {
+ return request('/api/supabase_config', null)
+}
+
+export function getAppConfig(): Promise {
+ return request('/api/config', null)
+}
+
+/** The caller's health data, or null when the server has none for them yet. */
+export async function getHealth(getToken: TokenProvider): Promise {
+ const body = await request('/api/health', getToken)
+ return 'error' in body ? null : body
+}
+```
+
+- [ ] **Step 6: Run to verify pass, then the full checks**
+
+Run: `npm --prefix web test` -> Expected: all PASS.
+Run: `npm --prefix web run lint && npm --prefix web run typecheck` -> Expected: exit 0.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add web/src
+git commit -m "feat(web): health types and typed API client with Bearer auth"
+```
+
+---
+
+### Task 3: Supabase client and AuthProvider
+
+**Files:**
+- Create: `web/src/lib/supabase/client.ts`, `web/src/lib/supabase/auth.tsx`, `web/src/lib/supabase/auth.test.tsx`
+
+**Interfaces:**
+- Consumes: `getSupabaseConfig` from `../api/client`.
+- Produces (`client.ts`): `getSupabase(): Promise` — resolves a cached client, or `null` when the config has no URL/key; a failed config fetch is not cached (next call retries).
+- Produces (`auth.tsx`):
+ - `type AuthStatus = 'loading' | 'signedOut' | 'signedIn' | 'unconfigured' | 'error'`
+ - `interface AuthResult { error: string | null; notice: string | null }`
+ - `interface AuthContextValue { status: AuthStatus; email: string | null; getToken: () => Promise; signIn(email: string, password: string): Promise; signUp(email: string, password: string): Promise; signInWithGoogle(): Promise; signOut(): Promise }`
+ - `AuthContext` (React context, exported for tests), `AuthProvider({ children, loadClient? })` (`loadClient` defaults to `getSupabase`; injectable for tests), `useAuth(): AuthContextValue` (throws outside a provider).
+ - Google redirect target: `${window.location.origin}${import.meta.env.BASE_URL}` (i.e. `.../app/`).
+
+- [ ] **Step 1: Write the failing tests** — `web/src/lib/supabase/auth.test.tsx`
+
+```tsx
+import type { Session, SupabaseClient } from '@supabase/supabase-js'
+import { act, render, screen, waitFor } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import { AuthProvider, useAuth, type AuthContextValue } from './auth'
+
+const session = { access_token: 'tok-a', user: { email: 'a@test.local' } } as unknown as Session
+
+function fakeClient(initial: Session | null) {
+ let listener: ((event: string, s: Session | null) => void) | undefined
+ const unsubscribe = vi.fn()
+ const auth = {
+ getSession: vi.fn().mockResolvedValue({ data: { session: initial } }),
+ onAuthStateChange: vi.fn((cb: (event: string, s: Session | null) => void) => {
+ listener = cb
+ return { data: { subscription: { unsubscribe } } }
+ }),
+ signInWithPassword: vi.fn().mockResolvedValue({ error: null }),
+ signUp: vi.fn().mockResolvedValue({ data: { session: null }, error: null }),
+ signInWithOAuth: vi.fn().mockResolvedValue({ error: null }),
+ signOut: vi.fn().mockResolvedValue({ error: null }),
+ }
+ return {
+ client: { auth } as unknown as SupabaseClient,
+ auth,
+ unsubscribe,
+ emit: (s: Session | null) => listener?.(s ? 'SIGNED_IN' : 'SIGNED_OUT', s),
+ }
+}
+
+let ctx!: AuthContextValue
+function Capture() {
+ ctx = useAuth()
+ return (
+ <>
+ {ctx.status}
+ {ctx.email ?? ''}
+ >
+ )
+}
+
+function renderProvider(loadClient: () => Promise) {
+ return render(
+
+
+ ,
+ )
+}
+
+const expectStatus = (value: string) =>
+ waitFor(() => expect(screen.getByTestId('status')).toHaveTextContent(value))
+
+describe('AuthProvider', () => {
+ it('starts loading, then reports signedIn with the email when a session exists', async () => {
+ const { client } = fakeClient(session)
+ renderProvider(async () => client)
+ expect(screen.getByTestId('status')).toHaveTextContent('loading')
+ await expectStatus('signedIn')
+ expect(screen.getByTestId('email')).toHaveTextContent('a@test.local')
+ })
+
+ it('reports signedOut when there is no session', async () => {
+ const { client } = fakeClient(null)
+ renderProvider(async () => client)
+ await expectStatus('signedOut')
+ })
+
+ it('reports unconfigured when Supabase is not configured (local dev)', async () => {
+ renderProvider(async () => null)
+ await expectStatus('unconfigured')
+ expect(await ctx.getToken()).toBeNull()
+ })
+
+ it('reports error when the Supabase config cannot be loaded', async () => {
+ renderProvider(async () => {
+ throw new Error('network down')
+ })
+ await expectStatus('error')
+ })
+
+ it('follows auth state changes', async () => {
+ const { client, emit } = fakeClient(null)
+ renderProvider(async () => client)
+ await expectStatus('signedOut')
+ act(() => emit(session))
+ await expectStatus('signedIn')
+ act(() => emit(null))
+ await expectStatus('signedOut')
+ })
+
+ it('getToken returns the current access token', async () => {
+ const { client } = fakeClient(session)
+ renderProvider(async () => client)
+ await expectStatus('signedIn')
+ expect(await ctx.getToken()).toBe('tok-a')
+ })
+
+ it('signIn forwards credentials and returns the Supabase error message', async () => {
+ const { client, auth } = fakeClient(null)
+ auth.signInWithPassword.mockResolvedValueOnce({ error: { message: 'Invalid login credentials' } })
+ renderProvider(async () => client)
+ await expectStatus('signedOut')
+ let result!: Awaited>
+ await act(async () => {
+ result = await ctx.signIn('a@test.local', 'pw')
+ })
+ expect(auth.signInWithPassword).toHaveBeenCalledWith({ email: 'a@test.local', password: 'pw' })
+ expect(result).toEqual({ error: 'Invalid login credentials', notice: null })
+ })
+
+ it('signUp without a session asks the user to confirm their email', async () => {
+ const { client } = fakeClient(null)
+ renderProvider(async () => client)
+ await expectStatus('signedOut')
+ let result!: Awaited>
+ await act(async () => {
+ result = await ctx.signUp('new@test.local', 'secret1')
+ })
+ expect(result.error).toBeNull()
+ expect(result.notice).toMatch(/confirm/i)
+ })
+
+ it('signUp surfaces Supabase errors', async () => {
+ const { client, auth } = fakeClient(null)
+ auth.signUp.mockResolvedValueOnce({ data: { session: null }, error: { message: 'User already registered' } })
+ renderProvider(async () => client)
+ await expectStatus('signedOut')
+ let result!: Awaited>
+ await act(async () => {
+ result = await ctx.signUp('a@test.local', 'secret1')
+ })
+ expect(result).toEqual({ error: 'User already registered', notice: null })
+ })
+
+ it('signInWithGoogle redirects back to the SPA base path', async () => {
+ const { client, auth } = fakeClient(null)
+ renderProvider(async () => client)
+ await expectStatus('signedOut')
+ await act(async () => {
+ await ctx.signInWithGoogle()
+ })
+ expect(auth.signInWithOAuth).toHaveBeenCalledWith({
+ provider: 'google',
+ options: { redirectTo: `${window.location.origin}/app/` },
+ })
+ })
+
+ it('signOut signs out of Supabase', async () => {
+ const { client, auth } = fakeClient(session)
+ renderProvider(async () => client)
+ await expectStatus('signedIn')
+ await act(async () => {
+ await ctx.signOut()
+ })
+ expect(auth.signOut).toHaveBeenCalled()
+ })
+
+ it('unsubscribes from auth changes on unmount', async () => {
+ const { client, unsubscribe } = fakeClient(null)
+ const { unmount } = renderProvider(async () => client)
+ await expectStatus('signedOut')
+ unmount()
+ expect(unsubscribe).toHaveBeenCalled()
+ })
+})
+```
+
+- [ ] **Step 2: Run to verify failure**
+
+Run: `npm --prefix web test -- src/lib/supabase/auth.test.tsx`
+Expected: FAIL, cannot resolve `./auth`.
+
+- [ ] **Step 3: Implement the client** — `web/src/lib/supabase/client.ts`
+
+```ts
+import { createClient, type SupabaseClient } from '@supabase/supabase-js'
+import { getSupabaseConfig } from '../api/client'
+
+let clientPromise: Promise | null = null
+
+/**
+ * The Supabase client, built once from the runtime config served by the API.
+ * Resolves to null when Supabase is not configured (local dev). A failed config
+ * fetch is not cached, so the next call retries.
+ */
+export function getSupabase(): Promise {
+ clientPromise ??= getSupabaseConfig()
+ .then((cfg) => (cfg.url && cfg.anonKey ? createClient(cfg.url, cfg.anonKey) : null))
+ .catch((err: unknown) => {
+ clientPromise = null
+ throw err
+ })
+ return clientPromise
+}
+```
+
+- [ ] **Step 4: Implement the provider** — `web/src/lib/supabase/auth.tsx`
+
+```tsx
+import type { Session, SupabaseClient } from '@supabase/supabase-js'
+import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from 'react'
+import { getSupabase } from './client'
+
+export type AuthStatus = 'loading' | 'signedOut' | 'signedIn' | 'unconfigured' | 'error'
+
+export interface AuthResult {
+ error: string | null
+ notice: string | null
+}
+
+export interface AuthContextValue {
+ status: AuthStatus
+ email: string | null
+ getToken: () => Promise
+ signIn: (email: string, password: string) => Promise
+ signUp: (email: string, password: string) => Promise
+ signInWithGoogle: () => Promise
+ signOut: () => Promise
+}
+
+export const AuthContext = createContext(null)
+
+const NOT_CONFIGURED: AuthResult = { error: 'Sign-in is not configured.', notice: null }
+
+export function AuthProvider({
+ children,
+ loadClient = getSupabase,
+}: {
+ children: ReactNode
+ loadClient?: () => Promise
+}) {
+ const [client, setClient] = useState(null)
+ const [session, setSession] = useState(null)
+ const [status, setStatus] = useState('loading')
+
+ useEffect(() => {
+ let cancelled = false
+ let unsubscribe: (() => void) | undefined
+
+ loadClient()
+ .then(async (c) => {
+ if (cancelled) return
+ if (!c) {
+ setStatus('unconfigured')
+ return
+ }
+ setClient(c)
+ const { data } = await c.auth.getSession()
+ if (cancelled) return
+ setSession(data.session)
+ setStatus(data.session ? 'signedIn' : 'signedOut')
+ const { data: sub } = c.auth.onAuthStateChange((_event, next) => {
+ setSession(next)
+ setStatus(next ? 'signedIn' : 'signedOut')
+ })
+ unsubscribe = () => sub.subscription.unsubscribe()
+ })
+ .catch(() => {
+ if (!cancelled) setStatus('error')
+ })
+
+ return () => {
+ cancelled = true
+ unsubscribe?.()
+ }
+ }, [loadClient])
+
+ const value = useMemo(
+ () => ({
+ status,
+ email: session?.user.email ?? null,
+ getToken: async () => {
+ if (!client) return null
+ const { data } = await client.auth.getSession()
+ return data.session?.access_token ?? null
+ },
+ signIn: async (email, password) => {
+ if (!client) return NOT_CONFIGURED
+ const { error } = await client.auth.signInWithPassword({ email, password })
+ return { error: error?.message ?? null, notice: null }
+ },
+ signUp: async (email, password) => {
+ if (!client) return NOT_CONFIGURED
+ const { data, error } = await client.auth.signUp({ email, password })
+ if (error) return { error: error.message, notice: null }
+ return {
+ error: null,
+ notice: data.session ? null : 'Check your email to confirm your account, then sign in.',
+ }
+ },
+ signInWithGoogle: async () => {
+ if (!client) return NOT_CONFIGURED
+ const { error } = await client.auth.signInWithOAuth({
+ provider: 'google',
+ options: { redirectTo: `${window.location.origin}${import.meta.env.BASE_URL}` },
+ })
+ return { error: error?.message ?? null, notice: null }
+ },
+ signOut: async () => {
+ await client?.auth.signOut()
+ },
+ }),
+ [client, session, status],
+ )
+
+ return {children}
+}
+
+export function useAuth(): AuthContextValue {
+ const value = useContext(AuthContext)
+ if (!value) throw new Error('useAuth must be used inside ')
+ return value
+}
+```
+
+- [ ] **Step 5: Run to verify pass, then the full checks**
+
+Run: `npm --prefix web test` -> Expected: all PASS.
+Run: `npm --prefix web run lint && npm --prefix web run typecheck` -> Expected: exit 0. (If lint flags `ctx!` / render-time assignment in the test file, keep the pattern and add a one-line `// eslint-disable-next-line` for that exact rule only, noting it in the report.)
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add web/src
+git commit -m "feat(web): runtime Supabase client and AuthProvider (email, Google, unconfigured mode)"
+```
+
+---
+
+### Task 4: App shell, route guard, login page
+
+**Files:**
+- Create: `web/src/test/utils.tsx`, `web/src/app/ProtectedRoute.tsx`, `web/src/app/ProtectedRoute.test.tsx`, `web/src/app/Shell.tsx`, `web/src/app/Shell.test.tsx`, `web/src/app/PlaceholderPage.tsx`, `web/src/features/auth/LoginPage.tsx`, `web/src/features/auth/LoginPage.test.tsx`
+
+**Interfaces:**
+- Consumes: `AuthContext`, `AuthContextValue`, `useAuth` from `../lib/supabase/auth` (Task 3).
+- Produces (`test/utils.tsx`): `authValue(overrides?: Partial): AuthContextValue` (default signed-in `a@test.local`, token `token-a`, `vi.fn` actions); `renderAt(ui: ReactElement, opts?: { route?: string; auth?: AuthContextValue }): RenderResult` — wraps in `QueryClientProvider` (retry off), `AuthContext.Provider`, `MemoryRouter`.
+- Produces: `ProtectedRoute()` (layout route: loading -> `role="status"` "Loading…"; error -> `role="alert"` "Can't reach the Somada server. Try again in a moment."; signedOut -> ` `; signedIn/unconfigured -> ` `); `Shell()` (layout route: header with brand, `nav[aria-label="Main"]` with NavLinks Dashboard `/` (end), Insights `/insights`, Settings `/settings`; account area with email + "Sign out" only when `status === 'signedIn'`; ` `); `PlaceholderPage({ title, children })`; `LoginPage()`.
+
+- [ ] **Step 1: Write test utilities** — `web/src/test/utils.tsx`
+
+```tsx
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { render } from '@testing-library/react'
+import type { ReactElement } from 'react'
+import { MemoryRouter } from 'react-router-dom'
+import { vi } from 'vitest'
+import { AuthContext, type AuthContextValue } from '../lib/supabase/auth'
+
+export function authValue(overrides: Partial = {}): AuthContextValue {
+ return {
+ status: 'signedIn',
+ email: 'a@test.local',
+ getToken: async () => 'token-a',
+ signIn: vi.fn().mockResolvedValue({ error: null, notice: null }),
+ signUp: vi.fn().mockResolvedValue({ error: null, notice: null }),
+ signInWithGoogle: vi.fn().mockResolvedValue({ error: null, notice: null }),
+ signOut: vi.fn().mockResolvedValue(undefined),
+ ...overrides,
+ }
+}
+
+export function renderAt(
+ ui: ReactElement,
+ { route = '/', auth = authValue() }: { route?: string; auth?: AuthContextValue } = {},
+) {
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
+ return render(
+
+
+ {ui}
+
+ ,
+ )
+}
+```
+
+- [ ] **Step 2: Write the failing tests**
+
+`web/src/app/ProtectedRoute.test.tsx`:
+```tsx
+import { screen } from '@testing-library/react'
+import { Route, Routes } from 'react-router-dom'
+import { describe, expect, it } from 'vitest'
+import { authValue, renderAt } from '../test/utils'
+import { ProtectedRoute } from './ProtectedRoute'
+
+function renderGuarded(status: Parameters[0]) {
+ return renderAt(
+
+ login page
} />
+ }>
+ secret content} />
+
+ ,
+ { auth: authValue(status) },
+ )
+}
+
+describe('ProtectedRoute', () => {
+ it('shows a loading status while the session is being checked', () => {
+ renderGuarded({ status: 'loading' })
+ expect(screen.getByRole('status')).toHaveTextContent('Loading')
+ expect(screen.queryByText('secret content')).not.toBeInTheDocument()
+ })
+
+ it('redirects signed-out visitors to /login', () => {
+ renderGuarded({ status: 'signedOut', email: null })
+ expect(screen.getByText('login page')).toBeInTheDocument()
+ expect(screen.queryByText('secret content')).not.toBeInTheDocument()
+ })
+
+ it('renders the protected content when signed in', () => {
+ renderGuarded({ status: 'signedIn' })
+ expect(screen.getByText('secret content')).toBeInTheDocument()
+ })
+
+ it('lets visitors in when auth is not configured (local dev)', () => {
+ renderGuarded({ status: 'unconfigured', email: null })
+ expect(screen.getByText('secret content')).toBeInTheDocument()
+ })
+
+ it('shows an alert when the server cannot be reached', () => {
+ renderGuarded({ status: 'error', email: null })
+ expect(screen.getByRole('alert')).toHaveTextContent("Can't reach the Somada server")
+ expect(screen.queryByText('secret content')).not.toBeInTheDocument()
+ })
+})
+```
+
+`web/src/app/Shell.test.tsx`:
+```tsx
+import { screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { Route, Routes } from 'react-router-dom'
+import { describe, expect, it, vi } from 'vitest'
+import { authValue, renderAt } from '../test/utils'
+import { Shell } from './Shell'
+
+function renderShell(auth = authValue()) {
+ return renderAt(
+
+ }>
+ page body} />
+
+ ,
+ { auth },
+ )
+}
+
+describe('Shell', () => {
+ it('has persistent navigation to Dashboard, Insights and Settings', () => {
+ renderShell()
+ const nav = screen.getByRole('navigation', { name: 'Main' })
+ expect(nav).toContainElement(screen.getByRole('link', { name: 'Dashboard' }))
+ expect(screen.getByRole('link', { name: 'Dashboard' })).toHaveAttribute('href', '/')
+ expect(screen.getByRole('link', { name: 'Insights' })).toHaveAttribute('href', '/insights')
+ expect(screen.getByRole('link', { name: 'Settings' })).toHaveAttribute('href', '/settings')
+ expect(screen.getByText('page body')).toBeInTheDocument()
+ })
+
+ it('shows the account email and signs out on request', async () => {
+ const signOut = vi.fn().mockResolvedValue(undefined)
+ renderShell(authValue({ signOut }))
+ expect(screen.getByText('a@test.local')).toBeInTheDocument()
+ await userEvent.click(screen.getByRole('button', { name: 'Sign out' }))
+ expect(signOut).toHaveBeenCalledTimes(1)
+ })
+
+ it('hides the account area when auth is not configured', () => {
+ renderShell(authValue({ status: 'unconfigured', email: null }))
+ expect(screen.queryByRole('button', { name: 'Sign out' })).not.toBeInTheDocument()
+ })
+})
+```
+
+`web/src/features/auth/LoginPage.test.tsx`:
+```tsx
+import { screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { Route, Routes } from 'react-router-dom'
+import { describe, expect, it, vi } from 'vitest'
+import { authValue, renderAt } from '../../test/utils'
+import { LoginPage } from './LoginPage'
+
+function renderLogin(auth = authValue({ status: 'signedOut', email: null })) {
+ return renderAt(
+
+ } />
+ home} />
+ ,
+ { route: '/login', auth },
+ )
+}
+
+async function fillCredentials() {
+ await userEvent.type(screen.getByLabelText('Email'), 'a@test.local')
+ await userEvent.type(screen.getByLabelText('Password'), 'secret1')
+}
+
+describe('LoginPage', () => {
+ it('signs in with the typed credentials', async () => {
+ const signIn = vi.fn().mockResolvedValue({ error: null, notice: null })
+ renderLogin(authValue({ status: 'signedOut', email: null, signIn }))
+ await fillCredentials()
+ await userEvent.click(screen.getByRole('button', { name: 'Sign in' }))
+ expect(signIn).toHaveBeenCalledWith('a@test.local', 'secret1')
+ })
+
+ it('shows the error message when sign-in fails', async () => {
+ const signIn = vi.fn().mockResolvedValue({ error: 'Invalid login credentials', notice: null })
+ renderLogin(authValue({ status: 'signedOut', email: null, signIn }))
+ await fillCredentials()
+ await userEvent.click(screen.getByRole('button', { name: 'Sign in' }))
+ expect(await screen.findByRole('alert')).toHaveTextContent('Invalid login credentials')
+ })
+
+ it('creates an account and shows the confirmation notice', async () => {
+ const signUp = vi
+ .fn()
+ .mockResolvedValue({ error: null, notice: 'Check your email to confirm your account, then sign in.' })
+ renderLogin(authValue({ status: 'signedOut', email: null, signUp }))
+ await fillCredentials()
+ await userEvent.click(screen.getByRole('button', { name: 'Create account' }))
+ expect(signUp).toHaveBeenCalledWith('a@test.local', 'secret1')
+ expect(await screen.findByRole('status')).toHaveTextContent('Check your email')
+ })
+
+ it('offers Google sign-in', async () => {
+ const signInWithGoogle = vi.fn().mockResolvedValue({ error: null, notice: null })
+ renderLogin(authValue({ status: 'signedOut', email: null, signInWithGoogle }))
+ await userEvent.click(screen.getByRole('button', { name: 'Continue with Google' }))
+ expect(signInWithGoogle).toHaveBeenCalledTimes(1)
+ })
+
+ it('redirects to the dashboard once signed in', () => {
+ renderLogin(authValue({ status: 'signedIn' }))
+ expect(screen.getByText('home')).toBeInTheDocument()
+ })
+})
+```
+
+- [ ] **Step 3: Run to verify failure**
+
+Run: `npm --prefix web test -- src/app src/features/auth`
+Expected: FAIL, cannot resolve `./ProtectedRoute`, `./Shell`, `./LoginPage`.
+
+- [ ] **Step 4: Implement**
+
+`web/src/app/ProtectedRoute.tsx`:
+```tsx
+import { Navigate, Outlet } from 'react-router-dom'
+import { useAuth } from '../lib/supabase/auth'
+
+export function ProtectedRoute() {
+ const { status } = useAuth()
+ if (status === 'loading') return Loading…
+ if (status === 'error') {
+ return Can't reach the Somada server. Try again in a moment.
+ }
+ if (status === 'signedOut') return
+ return
+}
+```
+
+`web/src/app/Shell.tsx`:
+```tsx
+import { NavLink, Outlet } from 'react-router-dom'
+import { useAuth } from '../lib/supabase/auth'
+
+export function Shell() {
+ const { status, email, signOut } = useAuth()
+ return (
+
+ )
+}
+```
+
+`web/src/app/PlaceholderPage.tsx`:
+```tsx
+import type { ReactNode } from 'react'
+
+export function PlaceholderPage({ title, children }: { title: string; children: ReactNode }) {
+ return (
+
+ )
+}
+```
+
+`web/src/features/auth/LoginPage.tsx`:
+```tsx
+import { useState } from 'react'
+import { Navigate } from 'react-router-dom'
+import { useAuth, type AuthResult } from '../../lib/supabase/auth'
+
+type Message = { kind: 'error' | 'notice'; text: string }
+
+export function LoginPage() {
+ const { status, signIn, signUp, signInWithGoogle } = useAuth()
+ const [email, setEmail] = useState('')
+ const [password, setPassword] = useState('')
+ const [message, setMessage] = useState(null)
+ const [busy, setBusy] = useState(false)
+
+ if (status === 'signedIn' || status === 'unconfigured') return
+
+ async function run(action: () => Promise) {
+ setBusy(true)
+ setMessage(null)
+ const result = await action()
+ setBusy(false)
+ if (result.error) setMessage({ kind: 'error', text: result.error })
+ else if (result.notice) setMessage({ kind: 'notice', text: result.notice })
+ }
+
+ return (
+
+ )
+}
+```
+
+- [ ] **Step 5: Run to verify pass, then the full checks**
+
+Run: `npm --prefix web test` -> Expected: all PASS.
+Run: `npm --prefix web run lint && npm --prefix web run typecheck` -> Expected: exit 0.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add web/src
+git commit -m "feat(web): app shell, protected routes and login page"
+```
+
+---
+
+### Task 5: Dashboard placeholder, route table, and app entry
+
+**Files:**
+- Create: `web/src/features/dashboard/DashboardPage.tsx`, `web/src/features/dashboard/DashboardPage.test.tsx`, `web/src/app/AppRoutes.tsx`, `web/src/app/AppRoutes.test.tsx`, `web/src/styles.css`
+- Modify: `web/src/main.tsx` (replace the Task 1 placeholder)
+
+**Interfaces:**
+- Consumes: `useAuth`, `getHealth`, `ApiError`, `HealthData`, `ProtectedRoute`, `Shell`, `PlaceholderPage`, `LoginPage`, test helpers `renderAt`, `authValue`, `stubFetch`, `healthFixture`.
+- Produces: `DashboardPage()` — TanStack Query key `['health', email]`; states: pending -> `role="status"` "Loading your data…"; error -> `role="alert"` with the error message; `null` data -> heading "No health data yet" plus a link to the classic app; data -> `Dashboard `, `"{summary.total_days} days of data"`, and when `data.is_demo` a `role="status"` banner "You're viewing sample data" with a link `Upload your own data` to `/`. `AppRoutes()` — routes: `/login`, then under `ProtectedRoute` > `Shell`: index `DashboardPage`, `insights`, `settings` placeholders, `*` -> ` `.
+
+- [ ] **Step 1: Write the failing tests**
+
+`web/src/features/dashboard/DashboardPage.test.tsx`:
+```tsx
+import { screen } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { stubFetch } from '../../test/fetch'
+import { healthFixture } from '../../test/fixtures'
+import { authValue, renderAt } from '../../test/utils'
+import { DashboardPage } from './DashboardPage'
+
+afterEach(() => {
+ vi.unstubAllGlobals()
+})
+
+function ownData() {
+ const data = healthFixture()
+ data.summary.total_days = 120
+ return data
+}
+
+describe('DashboardPage', () => {
+ it('shows a loading status, then the size of the user\'s own dataset', async () => {
+ stubFetch(200, ownData())
+ renderAt( )
+ expect(screen.getByRole('status')).toHaveTextContent('Loading your data')
+ expect(await screen.findByRole('heading', { name: 'Dashboard' })).toBeInTheDocument()
+ expect(screen.getByText('120 days of data')).toBeInTheDocument()
+ expect(screen.queryByText(/sample data/i)).not.toBeInTheDocument()
+ })
+
+ it('requests health data with the signed-in user\'s token', async () => {
+ const calls = stubFetch(200, ownData())
+ renderAt( , { auth: authValue({ getToken: async () => 'token-b' }) })
+ await screen.findByRole('heading', { name: 'Dashboard' })
+ expect(calls[0].url).toBe('/api/health')
+ expect(calls[0].headers.get('Authorization')).toBe('Bearer token-b')
+ })
+
+ it('labels the shared demo dataset as sample data and links to the classic upload', async () => {
+ stubFetch(200, { ...ownData(), is_demo: true })
+ renderAt( )
+ expect(await screen.findByText(/You're viewing sample data/)).toBeInTheDocument()
+ expect(screen.getByRole('link', { name: 'Upload your own data' })).toHaveAttribute('href', '/')
+ })
+
+ it('tells new users there is no data yet when the server has none', async () => {
+ stubFetch(200, { error: 'No health data found.' })
+ renderAt( )
+ expect(await screen.findByRole('heading', { name: 'No health data yet' })).toBeInTheDocument()
+ })
+
+ it('shows the server error message when the request fails', async () => {
+ stubFetch(401, { error: 'Sign in required.' })
+ renderAt( )
+ expect(await screen.findByRole('alert')).toHaveTextContent('Sign in required.')
+ })
+})
+```
+
+`web/src/app/AppRoutes.test.tsx`:
+```tsx
+import { screen } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { stubFetch } from '../test/fetch'
+import { healthFixture } from '../test/fixtures'
+import { authValue, renderAt } from '../test/utils'
+import { AppRoutes } from './AppRoutes'
+
+afterEach(() => {
+ vi.unstubAllGlobals()
+})
+
+describe('AppRoutes', () => {
+ it('sends signed-out visitors to the login page', () => {
+ renderAt( , { auth: authValue({ status: 'signedOut', email: null }) })
+ expect(screen.getByRole('heading', { name: 'Sign in to Somada' })).toBeInTheDocument()
+ })
+
+ it('renders the dashboard inside the shell for signed-in users', async () => {
+ stubFetch(200, healthFixture())
+ renderAt( )
+ expect(await screen.findByRole('heading', { name: 'Dashboard' })).toBeInTheDocument()
+ expect(screen.getByRole('navigation', { name: 'Main' })).toBeInTheDocument()
+ })
+
+ it('serves the Insights and Settings placeholders', () => {
+ renderAt( , { route: '/insights' })
+ expect(screen.getByRole('heading', { name: 'Insights' })).toBeInTheDocument()
+ })
+
+ it('redirects unknown paths to the dashboard', async () => {
+ stubFetch(200, healthFixture())
+ renderAt( , { route: '/nope' })
+ expect(await screen.findByRole('heading', { name: 'Dashboard' })).toBeInTheDocument()
+ })
+})
+```
+
+- [ ] **Step 2: Run to verify failure**
+
+Run: `npm --prefix web test -- src/features/dashboard src/app/AppRoutes.test.tsx`
+Expected: FAIL, cannot resolve `./DashboardPage` and `./AppRoutes`.
+
+- [ ] **Step 3: Implement**
+
+`web/src/features/dashboard/DashboardPage.tsx`:
+```tsx
+import { useQuery } from '@tanstack/react-query'
+import { getHealth } from '../../lib/api/client'
+import { useAuth } from '../../lib/supabase/auth'
+
+export function DashboardPage() {
+ const { email, getToken } = useAuth()
+ const { data, isPending, error } = useQuery({
+ queryKey: ['health', email],
+ queryFn: () => getHealth(getToken),
+ })
+
+ if (isPending) return Loading your data…
+ if (error) return {error.message}
+
+ if (data === null) {
+ return (
+
+ No health data yet
+ Uploading your Apple Health export is coming to the new app. For now, use the classic app.
+
+ Open the classic app
+
+
+ )
+ }
+
+ return (
+
+ Dashboard
+ {data.is_demo && (
+
+ You're viewing sample data. Upload your own data
+
+ )}
+ {data.summary.total_days} days of data
+
+ )
+}
+```
+
+`web/src/app/AppRoutes.tsx`:
+```tsx
+import { Navigate, Route, Routes } from 'react-router-dom'
+import { LoginPage } from '../features/auth/LoginPage'
+import { DashboardPage } from '../features/dashboard/DashboardPage'
+import { PlaceholderPage } from './PlaceholderPage'
+import { ProtectedRoute } from './ProtectedRoute'
+import { Shell } from './Shell'
+
+export function AppRoutes() {
+ return (
+
+ } />
+ }>
+ }>
+ } />
+
+ Chat with your health data is coming to the new app.
+
+ }
+ />
+
+ Account and AI settings are coming to the new app.
+
+ }
+ />
+
+
+ } />
+
+ )
+}
+```
+
+`web/src/main.tsx` (replace the placeholder):
+```tsx
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { StrictMode } from 'react'
+import { createRoot } from 'react-dom/client'
+import { BrowserRouter } from 'react-router-dom'
+import { AppRoutes } from './app/AppRoutes'
+import { AuthProvider } from './lib/supabase/auth'
+import './styles.css'
+
+const queryClient = new QueryClient()
+
+createRoot(document.getElementById('root')!).render(
+
+
+
+
+
+
+
+
+ ,
+)
+```
+
+`web/src/styles.css`:
+```css
+:root {
+ --bg: #ffffff;
+ --fg: #16181d;
+ --muted: #5b6270;
+ --line: #e2e5ea;
+ --accent: #0b6bcb;
+ --warn-bg: #fff6df;
+ --warn-fg: #7a5200;
+ color-scheme: light dark;
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --bg: #101216;
+ --fg: #eceef2;
+ --muted: #9aa3b2;
+ --line: #272b33;
+ --accent: #6cb0ff;
+ --warn-bg: #3a2f10;
+ --warn-fg: #ffd98a;
+ }
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ background: var(--bg);
+ color: var(--fg);
+ font: 16px/1.5 system-ui, -apple-system, 'Segoe UI', sans-serif;
+}
+
+a {
+ color: var(--accent);
+}
+
+.shell-header {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px 24px;
+ padding: 12px 16px;
+ border-bottom: 1px solid var(--line);
+}
+
+.brand {
+ font-weight: 700;
+}
+
+.shell-header nav {
+ display: flex;
+ gap: 16px;
+ flex: 1;
+}
+
+.shell-header nav a {
+ color: var(--muted);
+ text-decoration: none;
+}
+
+.shell-header nav a.active {
+ color: var(--fg);
+ font-weight: 600;
+}
+
+.account {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ color: var(--muted);
+}
+
+.shell-main {
+ max-width: 960px;
+ margin: 0 auto;
+ padding: 24px 16px;
+}
+
+.login {
+ display: grid;
+ gap: 12px;
+ max-width: 360px;
+ margin: 10vh auto 0;
+ padding: 0 16px;
+}
+
+.login label {
+ display: grid;
+ gap: 4px;
+}
+
+.login input {
+ padding: 8px;
+ font: inherit;
+}
+
+.actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+button {
+ padding: 8px 14px;
+ font: inherit;
+ cursor: pointer;
+}
+
+.demo-banner {
+ padding: 10px 12px;
+ border-radius: 6px;
+ background: var(--warn-bg);
+ color: var(--warn-fg);
+}
+```
+
+- [ ] **Step 4: Run all checks**
+
+Run: `npm --prefix web test` -> Expected: all PASS.
+Run: `npm --prefix web run lint && npm --prefix web run typecheck && npm --prefix web run build` -> Expected: exit 0, `web/dist/index.html` present.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add web/src
+git commit -m "feat(web): dashboard placeholder with demo banner, route table and app entry"
+```
+
+---
+
+### Task 6: Site assembly for Vercel
+
+**Files:**
+- Create: `scripts/build-site.mjs`
+- Modify: `vercel.json`
+
+**Interfaces:**
+- Consumes: `web/dist/` (from `npm --prefix web run build`), root `index.html`, root `icon.png`.
+- Produces: `public/` with the legacy page and icon at its root and the built SPA at `public/app/`; Vercel config that builds it and rewrites correctly.
+
+- [ ] **Step 1: Write the assembly script** — `scripts/build-site.mjs`
+
+```js
+// Assemble the static site for Vercel: legacy page at /, new SPA at /app/.
+import { cpSync, existsSync, mkdirSync, rmSync } from 'node:fs'
+import { dirname, join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const root = join(dirname(fileURLToPath(import.meta.url)), '..')
+const out = join(root, 'public')
+const webDist = join(root, 'web', 'dist')
+
+if (!existsSync(join(webDist, 'index.html'))) {
+ console.error('web/dist/index.html not found. Run `npm --prefix web run build` first.')
+ process.exit(1)
+}
+
+rmSync(out, { recursive: true, force: true })
+mkdirSync(out, { recursive: true })
+cpSync(join(root, 'index.html'), join(out, 'index.html'))
+cpSync(join(root, 'icon.png'), join(out, 'icon.png'))
+cpSync(webDist, join(out, 'app'), { recursive: true })
+console.log('Assembled public/ (legacy at /, new app at /app/)')
+```
+
+- [ ] **Step 2: Verify it fails without a build, then passes with one**
+
+```bash
+rm -rf web/dist public
+node scripts/build-site.mjs; echo "exit=$?"
+```
+Expected: the "not found" message and `exit=1`.
+
+```bash
+npm --prefix web run build && node scripts/build-site.mjs; echo "exit=$?"
+ls public public/app public/app/assets
+```
+Expected: `exit=0`; `public/index.html`, `public/icon.png`, `public/app/index.html`, and JS/CSS files under `public/app/assets/`. Confirm `public/index.html` is byte-identical to the root `index.html` (`cmp index.html public/index.html`).
+
+- [ ] **Step 3: Update `vercel.json`** (replace the whole file)
+
+```json
+{
+ "version": 2,
+ "buildCommand": "npm --prefix web ci && npm --prefix web run build && node scripts/build-site.mjs",
+ "outputDirectory": "public",
+ "rewrites": [
+ { "source": "/app/(.*)", "destination": "/app/index.html" },
+ { "source": "/((?!api/|app/).*)", "destination": "/index.html" }
+ ]
+}
+```
+Static files (e.g. `/app/assets/*.js`) are served before rewrites apply, so only non-file paths such as `/app/insights` fall back to the SPA. The second rule keeps the old behavior for the legacy page.
+
+- [ ] **Step 4: Verify JSON validity**
+
+Run: `node -e "JSON.parse(require('fs').readFileSync('vercel.json','utf8')); console.log('ok')"` -> Expected: `ok`.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add scripts/build-site.mjs vercel.json
+git commit -m "build: assemble legacy page at / and new app at /app/ for Vercel"
+```
+
+---
+
+### Task 7: CI job and docs
+
+**Files:**
+- Modify: `.github/workflows/tests.yml` (append a job), `README.md`, `CLAUDE.md`, `docs/superpowers/specs/2026-10-03-somada-modernization-design.md`
+
+- [ ] **Step 1: Add the `web` job** — append to the `jobs:` map in `.github/workflows/tests.yml` (same indentation as `api:`):
+
+```yaml
+ web:
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ defaults:
+ run:
+ working-directory: web
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: "22"
+ cache: npm
+ cache-dependency-path: web/package-lock.json
+ - run: npm ci
+ - run: npm run lint
+ - run: npm run typecheck
+ - run: npm test
+ - run: npm run build
+```
+
+- [ ] **Step 2: Validate the workflow YAML**
+
+Run: `python -c "import yaml,sys; d=yaml.safe_load(open('.github/workflows/tests.yml')); print(sorted(d['jobs']))"` -> Expected: `['api', 'web']`. (If PyYAML is unavailable, re-read the file and check indentation instead.)
+
+- [ ] **Step 3: Document it.** Preserve each file's existing line endings (check before editing).
+
+`README.md`: add a new section before `## Features`:
+
+```markdown
+## New app (work in progress)
+
+A rebuilt frontend lives in `web/` (Vite + React + TypeScript) and is served at `/app/` next to the classic page, which stays at `/` until the new app reaches parity. Today it has sign-in (email/password and Google), an app shell, and a dashboard placeholder; charts, upload and chat are still in the classic app.
+
+Develop it with two terminals:
+
+```bash
+vercel dev # serves the Python API on :3000 (needs SUPABASE_* env vars)
+npm --prefix web install
+npm --prefix web run dev # http://localhost:5173/app/ (proxies /api to :3000)
+```
+
+Checks: `npm --prefix web run lint`, `typecheck`, `test`, `build`. The Vercel build assembles both apps with `scripts/build-site.mjs`.
+
+For Google sign-in on the new app, add `https:///app/` (and any preview URLs) to the Redirect URLs in Supabase Authentication settings.
+```
+
+`CLAUDE.md`: (a) in the File Structure code block add lines `|- web/ # New frontend (Vite + React + TS), served at /app/` and `|- scripts/ # build-site.mjs, make_demo_seed.py` (keep the existing `supabase/` line); (b) under "Frontend Notes" append: "The new app in `web/` fetches its Supabase config at runtime from `/api/supabase_config` and sends the user's JWT as `Authorization: Bearer` on `/api/*` calls. `AuthStatus` is `unconfigured` in local dev (no Supabase env), which skips login. `index.html` (legacy) is still the production app at `/`."
+
+Spec: under "Open items" add the bullet: "Deviation: during rollout steps 1-4 the new app is served at `/app/` and the legacy page stays at `/`; the swap (new app at `/`, legacy removed) happens at cutover, instead of putting the legacy page at `/legacy`. Rationale: existing users and bookmarks keep working with zero redirects."
+
+- [ ] **Step 4: Run everything once more**
+
+Run: `python -m pytest -q && npm --prefix web test && npm --prefix web run lint && npm --prefix web run typecheck && npm --prefix web run build`
+Expected: all pass (54 Python tests plus the web tests).
+
+- [ ] **Step 5: Commit and open the PR**
+
+```bash
+git add .github README.md CLAUDE.md docs
+git commit -m "ci: add web job; docs for the new app"
+git push -u origin feat/web-scaffold
+gh pr create --base master --title "New frontend scaffold: Vite + React + TS app at /app/ with auth and shell (Step 1)" --body "Step 1 of the modernization (docs/superpowers/plans/2026-10-03-step1-web-scaffold.md). Adds web/ served at /app/ beside the untouched legacy page. See the plan's Task 8 for the preview-deploy checks, including the Supabase redirect-URL setting needed for Google sign-in."
+```
+(Pushing and opening the PR require the user's go-ahead in the session running this plan.)
+
+---
+
+### Task 8: Preview deploy and manual verification (user)
+
+Manual; needs Vercel and Supabase dashboards. Do before merging.
+
+- [ ] **Step 1: Confirm the two decisions flagged in this plan:** (a) new app at `/app/` with the legacy page staying at `/` (vs. the spec's original `/legacy` idea); (b) Google sign-in included for parity with the classic app.
+- [ ] **Step 2: Supabase redirect URLs.** Supabase dashboard -> Authentication -> URL Configuration: add `https://somada.vercel.app/app/` and the PR's preview URL pattern (e.g. `https://*-.vercel.app/app/**`) to the Redirect URLs.
+- [ ] **Step 3: Check the Vercel preview of the PR** (build must succeed; if Vercel fails to detect the Python functions after the `buildCommand` change, fix `vercel.json` before anything else):
+ - `/` still shows the classic app and `/api/health` still returns 401 when signed out.
+ - `/app/` redirects to `/app/login`; signing in with email works and lands on the dashboard; the dashboard shows "N days of data" and, for an account without its own upload, the "sample data" banner.
+ - Reload on `/app/insights` works (no 404); Sign out returns to the login page.
+ - Google sign-in returns to `/app/` signed in (only if the redirect URL was added).
+- [ ] **Step 4: Merge** once the above is green and Step 0's production cutover (plan 1, Task 9) is done or its preview Supabase is the scratch project.
+
+---
+
+## Self-Review
+
+**Spec coverage (rollout step 2 and "New frontend" section):** Vite/React/TS strict, React Router, TanStack Query, Vitest, Supabase from npm -> Tasks 1-3; auth + shell + navigation (Dashboard/Insights/Settings) -> Tasks 3-5; typed `/api/*` client with JWT -> Task 2; "preview URL" + deploy config -> Tasks 6, 8; CI lint/typecheck/tests -> Task 7. Deliberately deferred: charting library, Playwright, parsing/onboarding/chat/events/settings content (their own plans). The demo-data banner from Step 0's server marker is exercised end to end (Task 5).
+
+**Placeholder scan:** no TBD/TODO; every code step has complete code; the only contingencies are explicit "report instead of guessing" notes for dependency/tooling drift (Task 1 Steps 3 and 6, Task 3 Step 5).
+
+**Type consistency:** `AuthContextValue`, `AuthStatus`, `AuthResult`, `TokenProvider`, `getHealth`, `ApiError`, `stubFetch`, `healthFixture`, `authValue`, `renderAt` are named identically across the interfaces blocks, tests and implementations. Auth test status strings (`loading`, `signedOut`, `signedIn`, `unconfigured`, `error`) match the union. Button labels used in tests ("Sign in", "Create account", "Continue with Google", "Sign out") match the components.
+
+**Known risks:** (1) Vercel's handling of a Node `buildCommand` alongside zero-config Python functions is verified only on the preview deploy (Task 8); (2) dependency versions float to latest at install time, so the lockfile is the source of truth and small API drift (React Router, Vitest, ESLint plugin) is possible; (3) Google sign-in needs the Supabase redirect-URL setting that only you can change.
diff --git a/docs/superpowers/specs/2026-10-03-somada-modernization-design.md b/docs/superpowers/specs/2026-10-03-somada-modernization-design.md
index 3c0d05d..3a7cddf 100644
--- a/docs/superpowers/specs/2026-10-03-somada-modernization-design.md
+++ b/docs/superpowers/specs/2026-10-03-somada-modernization-design.md
@@ -99,3 +99,4 @@ Physician PDF export and share links (natural follow-up once data is per-user),
- Live Supabase schema and row contents (blocks the Step 0 migration decision).
- Charting library choice (Recharts vs Chart.js wrapper), to settle in the implementation plan.
- Deviation: the plan keeps `id` as the primary key (identity sequence) with `user_id` as a unique column rather than making `user_id` the primary key, so the demo row (null user_id) and the legacy row can coexist and rollback works.
+- Deviation: during rollout steps 1-4 the new app is served at `/app/` and the legacy page stays at `/`; the swap (new app at `/`, legacy removed) happens at cutover, instead of putting the legacy page at `/legacy`. Rationale: existing users and bookmarks keep working with zero redirects.
diff --git a/scripts/build-site.mjs b/scripts/build-site.mjs
new file mode 100644
index 0000000..65c3ad4
--- /dev/null
+++ b/scripts/build-site.mjs
@@ -0,0 +1,20 @@
+// Assemble the static site for Vercel: legacy page at /, new SPA at /app/.
+import { cpSync, existsSync, mkdirSync, rmSync } from 'node:fs'
+import { dirname, join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const root = join(dirname(fileURLToPath(import.meta.url)), '..')
+const out = join(root, 'public')
+const webDist = join(root, 'web', 'dist')
+
+if (!existsSync(join(webDist, 'index.html'))) {
+ console.error('web/dist/index.html not found. Run `npm --prefix web run build` first.')
+ process.exit(1)
+}
+
+rmSync(out, { recursive: true, force: true })
+mkdirSync(out, { recursive: true })
+cpSync(join(root, 'index.html'), join(out, 'index.html'))
+cpSync(join(root, 'icon.png'), join(out, 'icon.png'))
+cpSync(webDist, join(out, 'app'), { recursive: true })
+console.log('Assembled public/ (legacy at /, new app at /app/)')
diff --git a/vercel.json b/vercel.json
index f8ef4f9..38a68af 100644
--- a/vercel.json
+++ b/vercel.json
@@ -1,6 +1,15 @@
{
"version": 2,
+ "buildCommand": "npm --prefix web ci && npm --prefix web run build && node scripts/build-site.mjs",
+ "outputDirectory": "public",
+ "functions": {
+ "api/*.py": { "excludeFiles": "{web/**,public/**,docs/**,tests/**,supabase/**,scripts/**}" }
+ },
+ "redirects": [
+ { "source": "/app", "destination": "/app/", "permanent": false }
+ ],
"rewrites": [
- { "source": "/((?!api/).*)", "destination": "/index.html" }
+ { "source": "/app/(.*)", "destination": "/app/index.html" },
+ { "source": "/((?!api/|app/).*)", "destination": "/index.html" }
]
}
diff --git a/web/eslint.config.js b/web/eslint.config.js
new file mode 100644
index 0000000..22bf055
--- /dev/null
+++ b/web/eslint.config.js
@@ -0,0 +1,16 @@
+import js from '@eslint/js'
+import reactHooks from 'eslint-plugin-react-hooks'
+import tseslint from 'typescript-eslint'
+
+export default tseslint.config(
+ { ignores: ['dist'] },
+ js.configs.recommended,
+ ...tseslint.configs.recommended,
+ {
+ plugins: { 'react-hooks': reactHooks },
+ rules: {
+ 'react-hooks/rules-of-hooks': 'error',
+ 'react-hooks/exhaustive-deps': 'warn',
+ },
+ },
+)
diff --git a/web/index.html b/web/index.html
new file mode 100644
index 0000000..db3902d
--- /dev/null
+++ b/web/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Somada
+
+
+
+
+
+
diff --git a/web/package-lock.json b/web/package-lock.json
new file mode 100644
index 0000000..512e144
--- /dev/null
+++ b/web/package-lock.json
@@ -0,0 +1,3957 @@
+{
+ "name": "somada-web",
+ "version": "0.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "somada-web",
+ "version": "0.0.0",
+ "dependencies": {
+ "@supabase/supabase-js": "^2.117.2",
+ "@tanstack/react-query": "^5.104.1",
+ "react": "^19.3.0",
+ "react-dom": "^19.3.0",
+ "react-router-dom": "^7.18.4"
+ },
+ "devDependencies": {
+ "@eslint/js": "^10.0.1",
+ "@testing-library/jest-dom": "^7.0.1",
+ "@testing-library/react": "^16.3.3",
+ "@testing-library/user-event": "^14.6.7",
+ "@types/react": "^19.3.0",
+ "@types/react-dom": "^19.3.0",
+ "@vitejs/plugin-react": "^6.1.1",
+ "eslint": "^10.12.0",
+ "eslint-plugin-react-hooks": "^7.1.1",
+ "jsdom": "^29.1.1",
+ "typescript": "^6.0.3",
+ "typescript-eslint": "^8.71.0",
+ "vite": "^8.3.2",
+ "vitest": "^5.0.3"
+ },
+ "engines": {
+ "node": ">=20.19"
+ }
+ },
+ "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.7",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
+ "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/compat-data": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
+ "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/core": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
+ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.7",
+ "@babel/helper-compilation-targets": "^7.29.7",
+ "@babel/helper-module-transforms": "^7.29.7",
+ "@babel/helpers": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/template": "^7.29.7",
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7",
+ "@jridgewell/remapping": "^2.3.5",
+ "convert-source-map": "^2.0.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.3",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "node_modules/@babel/generator": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz",
+ "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.8",
+ "@babel/types": "^7.29.8",
+ "@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.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
+ "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.29.7",
+ "@babel/helper-validator-option": "^7.29.7",
+ "browserslist": "^4.24.0",
+ "lru-cache": "^5.1.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-globals": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
+ "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
+ "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
+ "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "@babel/traverse": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
+ "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
+ "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
+ "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.9",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz",
+ "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.8"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/runtime": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
+ "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/template": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
+ "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz",
+ "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.8",
+ "@babel/helper-globals": "^7.29.7",
+ "@babel/parser": "^7.29.8",
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.8",
+ "debug": "^4.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
+ "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7"
+ },
+ "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/@cacheable/memory": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz",
+ "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@cacheable/utils": "^2.5.0",
+ "@keyv/bigmap": "^1.3.1",
+ "hookified": "^1.15.1",
+ "keyv": "^5.6.0"
+ }
+ },
+ "node_modules/@cacheable/utils": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz",
+ "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hashery": "^1.5.1",
+ "keyv": "^5.6.0"
+ }
+ },
+ "node_modules/@csstools/color-helpers": {
+ "version": "6.1.2",
+ "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.2.tgz",
+ "integrity": "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==",
+ "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.4.3",
+ "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.3.tgz",
+ "integrity": "sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==",
+ "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.2",
+ "@csstools/css-tokenizer": "^4.0.2"
+ }
+ },
+ "node_modules/@csstools/css-color-parser": {
+ "version": "4.2.6",
+ "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.6.tgz",
+ "integrity": "sha512-iiPQ3iRWwnJkeEn6RIu6SJPr7hYrLz6XZ9s/QZl+2/LI5KQVjpl2fdmDSZKuD4xP6GMmMPgHFFXg6k1Wkz0Trg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/color-helpers": "^6.1.2",
+ "@csstools/css-calc": "^3.4.3"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.2",
+ "@csstools/css-tokenizer": "^4.0.2"
+ }
+ },
+ "node_modules/@csstools/css-parser-algorithms": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.2.tgz",
+ "integrity": "sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-tokenizer": "^4.0.2"
+ }
+ },
+ "node_modules/@csstools/css-syntax-patches-for-csstree": {
+ "version": "1.1.15",
+ "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.15.tgz",
+ "integrity": "sha512-J0u7HkVl2nzSlhsiTOp4AmwcUQ3D+mGEEKfBy/7To5/y7F2OHwyLrXfrhR0SMgr4p5Lo+eaMVSeai24zUcBIxA==",
+ "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.2",
+ "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.2.tgz",
+ "integrity": "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
+ "node_modules/@eslint-community/eslint-utils": {
+ "version": "4.10.1",
+ "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz",
+ "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==",
+ "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.7.0",
+ "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz",
+ "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==",
+ "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.3",
+ "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz",
+ "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==",
+ "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.16.0",
+ "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz",
+ "integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==",
+ "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.6.0",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
+ "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
+ "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/@keyv/bigmap": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz",
+ "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hashery": "^1.4.0",
+ "hookified": "^1.15.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "peerDependencies": {
+ "keyv": "^5.6.0"
+ }
+ },
+ "node_modules/@keyv/serialize": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz",
+ "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@oxc-project/types": {
+ "version": "0.152.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.152.0.tgz",
+ "integrity": "sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/oxc-project"
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm-eabi": {
+ "version": "1.2.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.12.tgz",
+ "integrity": "sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.2.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.12.tgz",
+ "integrity": "sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==",
+ "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.2.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.12.tgz",
+ "integrity": "sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==",
+ "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.2.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.12.tgz",
+ "integrity": "sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==",
+ "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.2.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.12.tgz",
+ "integrity": "sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==",
+ "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.2.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.12.tgz",
+ "integrity": "sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==",
+ "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.2.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.12.tgz",
+ "integrity": "sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==",
+ "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.2.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.12.tgz",
+ "integrity": "sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==",
+ "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.2.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.12.tgz",
+ "integrity": "sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==",
+ "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.2.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.12.tgz",
+ "integrity": "sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==",
+ "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.2.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.12.tgz",
+ "integrity": "sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==",
+ "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.2.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.12.tgz",
+ "integrity": "sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==",
+ "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.2.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.12.tgz",
+ "integrity": "sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.2.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.12.tgz",
+ "integrity": "sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==",
+ "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.2.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.12.tgz",
+ "integrity": "sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==",
+ "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/@supabase/auth-js": {
+ "version": "2.117.2",
+ "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.117.2.tgz",
+ "integrity": "sha512-Z3WnGwrphYZubrLbxp5Iv0tLA9A5GhvKzJ/ZXglxqwd2QEH2R4dXRltjVJ8sIn4xEW2BSFIGT0yALVJIgZeDYw==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "2.8.1"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ }
+ },
+ "node_modules/@supabase/functions-js": {
+ "version": "2.117.2",
+ "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.117.2.tgz",
+ "integrity": "sha512-6DT4ZIjmZxa9ANKaBIrjc86AyKrX8M376ynvNeOBBVad3eP7x3UHqWfDR2ynFkHGzCuOpr1A+kObmnahxuwuog==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "2.8.1"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ }
+ },
+ "node_modules/@supabase/phoenix": {
+ "version": "0.4.5",
+ "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.5.tgz",
+ "integrity": "sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw==",
+ "license": "MIT"
+ },
+ "node_modules/@supabase/postgrest-js": {
+ "version": "2.117.2",
+ "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.117.2.tgz",
+ "integrity": "sha512-V1Qhn+M8xzJqCasOxHZ2KG19Fj39PxU7weEAmOK65/KTsWCRXboB4hcQwFvxRvM7ZikeP9IZWNvQkfjvSxzYFw==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "2.8.1"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ }
+ },
+ "node_modules/@supabase/realtime-js": {
+ "version": "2.117.2",
+ "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.117.2.tgz",
+ "integrity": "sha512-lYXSAIg3eAKA58riUED6Vb+TCzF8jtx18uOoiCaVJ7ZMre6FWBpwB1MPeW+B6vykJT/hPdiuqwcSB7ILMS21ew==",
+ "license": "MIT",
+ "dependencies": {
+ "@supabase/phoenix": "0.4.5",
+ "tslib": "2.8.1"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ }
+ },
+ "node_modules/@supabase/storage-js": {
+ "version": "2.117.2",
+ "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.117.2.tgz",
+ "integrity": "sha512-8gAJoVaxZa/War2kFRfJMxGk4M190Q7lJ70BofwGlRxU/u9pIodd6XyTvmXkxOKXWuIYtkDaYPZrIAUt1T/7FQ==",
+ "license": "MIT",
+ "dependencies": {
+ "iceberg-js": "^0.8.1",
+ "tslib": "2.8.1"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ }
+ },
+ "node_modules/@supabase/supabase-js": {
+ "version": "2.117.2",
+ "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.117.2.tgz",
+ "integrity": "sha512-eSG2VKnHR+Clp1PmidZ1/weJ8PJwoybjva3L2GgKqFG4YDS1Iqmc61psKGZP5xw6OMT2O7ZorPR42PY6q1BOXg==",
+ "license": "MIT",
+ "dependencies": {
+ "@supabase/auth-js": "2.117.2",
+ "@supabase/functions-js": "2.117.2",
+ "@supabase/postgrest-js": "2.117.2",
+ "@supabase/realtime-js": "2.117.2",
+ "@supabase/storage-js": "2.117.2"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": ">=1.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@opentelemetry/api": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@tanstack/query-core": {
+ "version": "5.104.1",
+ "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.104.1.tgz",
+ "integrity": "sha512-TiRghcrGkUTE+M4JBxvpPSpBhxvIzQairw+cksrHYGr+2uwC77yW1SY9nYxnYRYOppvucf8rEg6b5byoz4rTpw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
+ "node_modules/@tanstack/react-query": {
+ "version": "5.104.1",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.104.1.tgz",
+ "integrity": "sha512-Zz70EgjahNI7aCz8BZxM3vUGU44ycxEAzMuCUjLeA7SVhhOkg3ZgPjPJPvHjs6Rmky/dtmnu1WzjGHNHmhCoZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/query-core": "5.104.1"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "react": "^18 || ^19"
+ }
+ },
+ "node_modules/@testing-library/dom": {
+ "version": "10.4.2",
+ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz",
+ "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==",
+ "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": "7.0.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.1.tgz",
+ "integrity": "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@adobe/css-tools": "^4.4.0",
+ "aria-query": "^5.0.0",
+ "css.escape": "^1.5.1",
+ "dom-accessibility-api": "^0.6.3",
+ "picocolors": "^1.1.1",
+ "redent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=22",
+ "npm": ">=6",
+ "yarn": ">=1"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": ">=10 <11",
+ "vitest": ">= 0.32"
+ },
+ "peerDependenciesMeta": {
+ "vitest": {
+ "optional": true
+ }
+ }
+ },
+ "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.3",
+ "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz",
+ "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.12.5"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": "^10.0.0",
+ "@types/react": "^18.0.0 || ^19.0.0",
+ "@types/react-dom": "^18.0.0 || ^19.0.0",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@testing-library/user-event": {
+ "version": "14.6.7",
+ "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.7.tgz",
+ "integrity": "sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": ">=7.21.4"
+ }
+ },
+ "node_modules/@types/aria-query": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
+ "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/@types/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/react": {
+ "version": "19.3.0",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
+ "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "19.3.0",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz",
+ "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^19.3.0"
+ }
+ },
+ "node_modules/@typescript-eslint/eslint-plugin": {
+ "version": "8.71.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.0.tgz",
+ "integrity": "sha512-pqcS9c1HxZTHt7End4nXqd0s5lJrrFzrgCkKFJrsbUnaL6M3+6oBFZaslg6Gjsl3argl2DDRFROnXARaZ2e4Nw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/regexpp": "^4.12.2",
+ "@typescript-eslint/scope-manager": "8.71.0",
+ "@typescript-eslint/type-utils": "8.71.0",
+ "@typescript-eslint/utils": "8.71.0",
+ "@typescript-eslint/visitor-keys": "8.71.0",
+ "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.71.0",
+ "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.12",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.12.tgz",
+ "integrity": "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/@typescript-eslint/parser": {
+ "version": "8.71.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.0.tgz",
+ "integrity": "sha512-CG4nPk1f2zc8yw4pALqHsFYH2hdo+h1T9daSp21+Hnxi9LOE3GT9hAfTKJCBXVNM2GmYs1eMEP615wPoeOgk3A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/scope-manager": "8.71.0",
+ "@typescript-eslint/types": "8.71.0",
+ "@typescript-eslint/typescript-estree": "8.71.0",
+ "@typescript-eslint/visitor-keys": "8.71.0",
+ "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.71.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.0.tgz",
+ "integrity": "sha512-aABjw5rjBacYONVPaPiWOCjJu0vEF4a25iQuodlmQYL1trtLZ0X/y+2Vzl3BKI1odM4LnwLE1oUDXYp1wzx1TQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/tsconfig-utils": "^8.71.0",
+ "@typescript-eslint/types": "^8.71.0",
+ "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.71.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.0.tgz",
+ "integrity": "sha512-gWF0BhUcnjZxSpLE8ngS/59n2SB0J3YqRxvX1+2aoRJk9hNtHSLOV+TcarFiOr5ipXm3yc1QrI4c9YZc8zyCxw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.71.0",
+ "@typescript-eslint/visitor-keys": "8.71.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/tsconfig-utils": {
+ "version": "8.71.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.0.tgz",
+ "integrity": "sha512-Z1UlWHADEK2Mlb9NpWfDeSjqoZ5EyrOv4R3eQpbkzqn/EwaIdOpXXupEA1+0ZIOSJSZZDBHG0BrQyN8zUG6Pwg==",
+ "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.71.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.0.tgz",
+ "integrity": "sha512-i8uO1qbdxeKgRnS5sCRt6On3/nfo2d2DwQe3Yvjx543zLy7r8ySqRuPPiIIXAhS03U0v5NfAFx+rUgxFzKKwNw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.71.0",
+ "@typescript-eslint/typescript-estree": "8.71.0",
+ "@typescript-eslint/utils": "8.71.0",
+ "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.71.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.0.tgz",
+ "integrity": "sha512-cJ4OoxPGWvFnBTnSZyaU+qJzGTqPTGJY+gDchj6cRyLRdmIdt4rcsE4twj+zPfrNiWuVi38wijHzShL++Z9atQ==",
+ "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.71.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.0.tgz",
+ "integrity": "sha512-PEEF4G5sLLWAS5BpPrUvms4ySZkiBQQZM4z+3ReI46axK5Vqr/vXBQatJQIZZOYdGyPUAKTtsrWzpqKuU+3DEw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/project-service": "8.71.0",
+ "@typescript-eslint/tsconfig-utils": "8.71.0",
+ "@typescript-eslint/types": "8.71.0",
+ "@typescript-eslint/visitor-keys": "8.71.0",
+ "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.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@typescript-eslint/utils": {
+ "version": "8.71.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.0.tgz",
+ "integrity": "sha512-pKR/tEMVrXZG23UFKUn5BQf3zfmfk7KQceI2cGzywZ5nxM5Eu3hEJU1utjWzydtzBbcJAQhHN8iPCxobHpPcZQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.9.1",
+ "@typescript-eslint/scope-manager": "8.71.0",
+ "@typescript-eslint/types": "8.71.0",
+ "@typescript-eslint/typescript-estree": "8.71.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/visitor-keys": {
+ "version": "8.71.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.0.tgz",
+ "integrity": "sha512-8eQ9R218XORK+KLosnf4bu/QsUXvUyVwTbArg7/0NMB1Pu87OJKvj4nhFblkYE8gQV73mW1dx1ptlPCkwRGa7A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.71.0",
+ "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.1.1",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz",
+ "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@rolldown/pluginutils": "^1.0.1"
+ },
+ "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",
+ "oxc-transform-react": "^0.145.0",
+ "vite": "^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@rolldown/plugin-babel": {
+ "optional": true
+ },
+ "babel-plugin-react-compiler": {
+ "optional": true
+ },
+ "oxc-transform-react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.3.tgz",
+ "integrity": "sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "0.3.31",
+ "@vitest/spy": "5.0.3",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^1.2.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.3.tgz",
+ "integrity": "sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/acorn": {
+ "version": "8.18.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
+ "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/acorn-jsx": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
+ "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ }
+ },
+ "node_modules/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",
+ "peer": true,
+ "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",
+ "peer": true,
+ "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.11.27",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.27.tgz",
+ "integrity": "sha512-ElY12DaROGuan+lMmZ8Cvo/ZUbXPe7Enc/9VU/b1T3Kp4dwytRcNdR8DoSJN5SNJT/CuvcCA0DHDVmMOCePdRQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/bidi-js": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz",
+ "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "require-from-string": "^2.0.2"
+ }
+ },
+ "node_modules/brace-expansion": {
+ "version": "5.0.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
+ "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.29.3",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz",
+ "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "baseline-browser-mapping": "^2.11.26",
+ "caniuse-lite": "^1.0.30001813",
+ "electron-to-chromium": "^1.5.439",
+ "node-releases": "^2.0.57",
+ "update-browserslist-db": "^1.3.3"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/cacheable": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz",
+ "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@cacheable/memory": "^2.2.0",
+ "@cacheable/utils": "^2.5.0",
+ "hookified": "^1.15.0",
+ "keyv": "^5.6.0",
+ "qified": "^0.10.1"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001814",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001814.tgz",
+ "integrity": "sha512-/Uaf1lAzr59XcMpW0o96WoEfr+VXK2OX4U9AgFoiSHsVJ4HppnIFUjtYzsyDH2+tgANaQb2/oxYGwCPapN1FpA==",
+ "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.3.0",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz",
+ "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==",
+ "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",
+ "peer": true
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.444",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.444.tgz",
+ "integrity": "sha512-5ss/uJfoDYDHT0lfJzT6FbcskIzROIOPf0BbbFkGcvDzoJU7i//9GDrwwIHQVmIsrAGiF3ihpADBRIsrEFt1rQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/entities": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",
+ "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/es-module-lexer": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
+ "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/escape-string-regexp": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/eslint": {
+ "version": "10.12.0",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.12.0.tgz",
+ "integrity": "sha512-npHjrHb2o4ehH3mE+YuxABR+0gyb0aVWCIolgJDArwCiHEIsonBm3ZKjel5b0U5+a+MbEvIyRCGCzD3eKKK2NA==",
+ "dev": true,
+ "license": "MIT",
+ "workspaces": [
+ "packages/*"
+ ],
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.8.0",
+ "@eslint-community/regexpp": "^4.12.2",
+ "@eslint/config-array": "^0.23.5",
+ "@eslint/config-helpers": "^0.7.0",
+ "@eslint/core": "^1.2.1",
+ "@eslint/plugin-kit": "^0.7.3",
+ "@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": "11.1.5 || >11.1.6 <12",
+ "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.5",
+ "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-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.4.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
+ "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-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": "11.1.5",
+ "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz",
+ "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flat-cache": "^6.1.23"
+ }
+ },
+ "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": "6.1.23",
+ "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz",
+ "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cacheable": "^2.5.0",
+ "flatted": "^3.4.2",
+ "hookified": "^1.15.0"
+ }
+ },
+ "node_modules/flatted": {
+ "version": "3.4.4",
+ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz",
+ "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==",
+ "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/hashery": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz",
+ "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hookified": "^1.15.0"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "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/hookified": {
+ "version": "1.15.1",
+ "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz",
+ "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "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/iceberg-js": {
+ "version": "0.8.1",
+ "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
+ "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.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/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.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
+ "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==",
+ "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-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": "5.6.0",
+ "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz",
+ "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@keyv/serialize": "^1.1.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.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
+ "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
+ "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.33.0",
+ "lightningcss-darwin-arm64": "1.33.0",
+ "lightningcss-darwin-x64": "1.33.0",
+ "lightningcss-freebsd-x64": "1.33.0",
+ "lightningcss-linux-arm-gnueabihf": "1.33.0",
+ "lightningcss-linux-arm64-gnu": "1.33.0",
+ "lightningcss-linux-arm64-musl": "1.33.0",
+ "lightningcss-linux-x64-gnu": "1.33.0",
+ "lightningcss-linux-x64-musl": "1.33.0",
+ "lightningcss-win32-arm64-msvc": "1.33.0",
+ "lightningcss-win32-x64-msvc": "1.33.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
+ "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
+ "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.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
+ "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
+ "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.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
+ "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
+ "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.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
+ "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
+ "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.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
+ "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
+ "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.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
+ "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
+ "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.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
+ "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
+ "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.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
+ "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
+ "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.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
+ "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
+ "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.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
+ "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
+ "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.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
+ "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
+ "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",
+ "peer": true,
+ "bin": {
+ "lz-string": "bin/bin.js"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "1.4.2",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz",
+ "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.6.0"
+ }
+ },
+ "node_modules/mdn-data": {
+ "version": "2.27.1",
+ "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
+ "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
+ "dev": true,
+ "license": "CC0-1.0"
+ },
+ "node_modules/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.6",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
+ "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "brace-expansion": "^5.0.8"
+ },
+ "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.19",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
+ "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==",
+ "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.57",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.57.tgz",
+ "integrity": "sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/obug": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz",
+ "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ }
+ },
+ "node_modules/optionator": {
+ "version": "0.9.4",
+ "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
+ "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "deep-is": "^0.1.3",
+ "fast-levenshtein": "^2.0.6",
+ "levn": "^0.4.1",
+ "prelude-ls": "^1.2.1",
+ "type-check": "^0.4.0",
+ "word-wrap": "^1.2.5"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/p-limit": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
+ "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "yocto-queue": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-locate": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
+ "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-limit": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/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/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.7",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.28",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
+ "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
+ "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.18",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/prelude-ls": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
+ "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/pretty-format": {
+ "version": "27.5.1",
+ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
+ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "ansi-regex": "^5.0.1",
+ "ansi-styles": "^5.0.0",
+ "react-is": "^17.0.1"
+ },
+ "engines": {
+ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+ }
+ },
+ "node_modules/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/qified": {
+ "version": "0.10.1",
+ "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz",
+ "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hookified": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/qified/node_modules/hookified": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz",
+ "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/react": {
+ "version": "19.3.0",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz",
+ "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.3.0",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz",
+ "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.28.0"
+ },
+ "peerDependencies": {
+ "react": "^19.3.0"
+ }
+ },
+ "node_modules/react-is": {
+ "version": "17.0.2",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
+ "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/react-router": {
+ "version": "7.18.4",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz",
+ "integrity": "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==",
+ "license": "MIT",
+ "dependencies": {
+ "cookie": "^1.0.1",
+ "set-cookie-parser": "^2.6.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=18",
+ "react-dom": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-router-dom": {
+ "version": "7.18.4",
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.4.tgz",
+ "integrity": "sha512-yrfmJHIpDG7taCpqKjT1G5B6q3O2K+RN8/fgNf0lTjCwiPbQ0ei6vXX9ZjQR+7ld8Tr7Z5xmyMnZ8YJrphWQUw==",
+ "license": "MIT",
+ "dependencies": {
+ "react-router": "7.18.4"
+ },
+ "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.2.12",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.12.tgz",
+ "integrity": "sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@oxc-project/types": "=0.152.0",
+ "@rolldown/pluginutils": "^1.0.0"
+ },
+ "bin": {
+ "rolldown": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "optionalDependencies": {
+ "@rolldown/binding-android-arm-eabi": "1.2.12",
+ "@rolldown/binding-android-arm64": "1.2.12",
+ "@rolldown/binding-darwin-arm64": "1.2.12",
+ "@rolldown/binding-darwin-x64": "1.2.12",
+ "@rolldown/binding-freebsd-x64": "1.2.12",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.2.12",
+ "@rolldown/binding-linux-arm64-gnu": "1.2.12",
+ "@rolldown/binding-linux-arm64-musl": "1.2.12",
+ "@rolldown/binding-linux-ppc64-gnu": "1.2.12",
+ "@rolldown/binding-linux-s390x-gnu": "1.2.12",
+ "@rolldown/binding-linux-x64-gnu": "1.2.12",
+ "@rolldown/binding-linux-x64-musl": "1.2.12",
+ "@rolldown/binding-openharmony-arm64": "1.2.12",
+ "@rolldown/binding-win32-arm64-msvc": "1.2.12",
+ "@rolldown/binding-win32-x64-msvc": "1.2.12"
+ }
+ },
+ "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.28.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz",
+ "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==",
+ "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/source-map-js": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
+ "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/std-env": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz",
+ "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==",
+ "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/tinybench": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.0.tgz",
+ "integrity": "sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/tinyexec": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz",
+ "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tldts": {
+ "version": "7.4.16",
+ "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.16.tgz",
+ "integrity": "sha512-QwBER5KMR86IIjpIiO7H/Z3IMJPsZ1A6RKPAqzTTgOyUQUSt9FdnKcqhTaJmkY6HVrgouZHZR0ncK5QxvmnQeg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tldts-core": "^7.4.16"
+ },
+ "bin": {
+ "tldts": "bin/cli.js"
+ }
+ },
+ "node_modules/tldts-core": {
+ "version": "7.4.16",
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.16.tgz",
+ "integrity": "sha512-MDolfaSJtlSK5Y0A1xl3277ekubZwobpBjugknDizI9O5Rm60a1m8k4ICK+MRsCDzPygT81mp3BBf5RKDlFRfA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tough-cookie": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",
+ "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "tldts": "^7.0.5"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
+ "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/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==",
+ "license": "0BSD"
+ },
+ "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",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/typescript-eslint": {
+ "version": "8.71.0",
+ "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.71.0.tgz",
+ "integrity": "sha512-fBdHYiqQ14RW6mOMXD14Svn82ZsCYAoQSzGRzyEjR59S5A2Krh/l7fGTOQ7iCr8gGy/mHVXtEF7s5fgjEdV0Pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/eslint-plugin": "8.71.0",
+ "@typescript-eslint/parser": "8.71.0",
+ "@typescript-eslint/typescript-estree": "8.71.0",
+ "@typescript-eslint/utils": "8.71.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/undici": {
+ "version": "7.30.0",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz",
+ "integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.18.1"
+ }
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz",
+ "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==",
+ "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.3.2",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.2.tgz",
+ "integrity": "sha512-SQr1x6W5vVSbROg7vsyXIaxK9b0G7zsT68acdWWRmnBUsgDieLCRG+Rep9WdZgcposvv/GSnr4GUUBqB3vXq6w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lightningcss": "^1.33.0",
+ "picomatch": "^4.0.7",
+ "postcss": "^8.5.28",
+ "rolldown": "~1.2.11",
+ "tinyglobby": "^0.2.17"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "@vitejs/devtools": "^0.7.1",
+ "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": "5.0.3",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.3.tgz",
+ "integrity": "sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/chai": "^5.2.2",
+ "@vitest/mocker": "5.0.3",
+ "chai": "^6.2.2",
+ "es-module-lexer": "^2.3.2",
+ "expect-type": "^1.4.0",
+ "magic-string": "^1.2.3",
+ "obug": "^2.1.4",
+ "picomatch": "^4.0.7",
+ "std-env": "^4.2.0",
+ "tinybench": "^6.1.4",
+ "tinyexec": "^1.3.0",
+ "tinyglobby": "^0.2.17",
+ "why-is-node-running": "3.2.1"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^22.12.0 || ^24.0.0 || >=26.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@opentelemetry/api": "^1.9.0",
+ "@types/node": "^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "5.0.3",
+ "@vitest/browser-preview": "5.0.3",
+ "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0",
+ "@vitest/coverage-istanbul": "5.0.3",
+ "@vitest/coverage-v8": "5.0.3",
+ "@vitest/ui": "5.0.3",
+ "happy-dom": "*",
+ "jsdom": "*",
+ "vite": "^6.4.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser-playwright": {
+ "optional": true
+ },
+ "@vitest/browser-preview": {
+ "optional": true
+ },
+ "@vitest/browser-webdriverio": {
+ "optional": true
+ },
+ "@vitest/coverage-istanbul": {
+ "optional": true
+ },
+ "@vitest/coverage-v8": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ },
+ "vite": {
+ "optional": false
+ }
+ }
+ },
+ "node_modules/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": "3.2.1",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz",
+ "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=20.11"
+ }
+ },
+ "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.6.5",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz",
+ "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
+ "node_modules/zod-validation-error": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz",
+ "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "peerDependencies": {
+ "zod": "^3.25.0 || ^4.0.0"
+ }
+ }
+ }
+}
diff --git a/web/package.json b/web/package.json
new file mode 100644
index 0000000..5b0b598
--- /dev/null
+++ b/web/package.json
@@ -0,0 +1,40 @@
+{
+ "name": "somada-web",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "engines": {
+ "node": "^22.13.0 || >=24"
+ },
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc --noEmit && vite build",
+ "typecheck": "tsc --noEmit",
+ "lint": "eslint src",
+ "test": "vitest run",
+ "test:watch": "vitest"
+ },
+ "dependencies": {
+ "@supabase/supabase-js": "^2.117.2",
+ "@tanstack/react-query": "^5.104.1",
+ "react": "^19.3.0",
+ "react-dom": "^19.3.0",
+ "react-router-dom": "^7.18.4"
+ },
+ "devDependencies": {
+ "@eslint/js": "^10.0.1",
+ "@testing-library/jest-dom": "^7.0.1",
+ "@testing-library/react": "^16.3.3",
+ "@testing-library/user-event": "^14.6.7",
+ "@types/react": "^19.3.0",
+ "@types/react-dom": "^19.3.0",
+ "@vitejs/plugin-react": "^6.1.1",
+ "eslint": "^10.12.0",
+ "eslint-plugin-react-hooks": "^7.1.1",
+ "jsdom": "^29.1.1",
+ "typescript": "^6.0.3",
+ "typescript-eslint": "^8.71.0",
+ "vite": "^8.3.2",
+ "vitest": "^5.0.3"
+ }
+}
diff --git a/web/src/app/AppRoutes.test.tsx b/web/src/app/AppRoutes.test.tsx
new file mode 100644
index 0000000..9f48ccf
--- /dev/null
+++ b/web/src/app/AppRoutes.test.tsx
@@ -0,0 +1,40 @@
+import { screen } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { stubFetch } from '../test/fetch'
+import { healthFixture } from '../test/fixtures'
+import { authValue, renderAt } from '../test/utils'
+import { AppRoutes } from './AppRoutes'
+
+afterEach(() => {
+ vi.unstubAllGlobals()
+})
+
+describe('AppRoutes', () => {
+ it('sends signed-out visitors to the login page', () => {
+ renderAt( , { auth: authValue({ status: 'signedOut', email: null }) })
+ expect(screen.getByRole('heading', { name: 'Sign in to Somada' })).toBeInTheDocument()
+ })
+
+ it('renders the dashboard inside the shell for signed-in users', async () => {
+ stubFetch(200, healthFixture())
+ renderAt( )
+ expect(await screen.findByRole('heading', { name: 'Dashboard' })).toBeInTheDocument()
+ expect(screen.getByRole('navigation', { name: 'Main' })).toBeInTheDocument()
+ })
+
+ it('serves the Insights placeholder', () => {
+ renderAt( , { route: '/insights' })
+ expect(screen.getByRole('heading', { name: 'Insights' })).toBeInTheDocument()
+ })
+
+ it('serves the Settings placeholder', () => {
+ renderAt( , { route: '/settings' })
+ expect(screen.getByRole('heading', { name: 'Settings' })).toBeInTheDocument()
+ })
+
+ it('redirects unknown paths to the dashboard', async () => {
+ stubFetch(200, healthFixture())
+ renderAt( , { route: '/nope' })
+ expect(await screen.findByRole('heading', { name: 'Dashboard' })).toBeInTheDocument()
+ })
+})
diff --git a/web/src/app/AppRoutes.tsx b/web/src/app/AppRoutes.tsx
new file mode 100644
index 0000000..5e09919
--- /dev/null
+++ b/web/src/app/AppRoutes.tsx
@@ -0,0 +1,36 @@
+import { Navigate, Route, Routes } from 'react-router-dom'
+import { LoginPage } from '../features/auth/LoginPage'
+import { DashboardPage } from '../features/dashboard/DashboardPage'
+import { PlaceholderPage } from './PlaceholderPage'
+import { ProtectedRoute } from './ProtectedRoute'
+import { Shell } from './Shell'
+
+export function AppRoutes() {
+ return (
+
+ } />
+ }>
+ }>
+ } />
+
+ Chat with your health data is coming to the new app.
+
+ }
+ />
+
+ Account and AI settings are coming to the new app.
+
+ }
+ />
+
+
+ } />
+
+ )
+}
diff --git a/web/src/app/ClearCacheOnSignOut.test.tsx b/web/src/app/ClearCacheOnSignOut.test.tsx
new file mode 100644
index 0000000..c3c66f6
--- /dev/null
+++ b/web/src/app/ClearCacheOnSignOut.test.tsx
@@ -0,0 +1,109 @@
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { describe, expect, it, vi } from 'vitest'
+import { render } from '@testing-library/react'
+import { AuthContext } from '../lib/supabase/auth'
+import { ClearCacheOnSignOut } from './ClearCacheOnSignOut'
+
+describe('ClearCacheOnSignOut', () => {
+ it('clears the query cache when user signs out', () => {
+ const queryClient = new QueryClient()
+ const testData = { summary: { total_days: 100 } }
+ queryClient.setQueryData(['health', 'a@test.local'], testData)
+
+ const { rerender } = render(
+
+
+
+ test
+
+ ,
+ )
+
+ expect(queryClient.getQueryData(['health', 'a@test.local'])).toEqual(testData)
+
+ rerender(
+
+
+
+ test
+
+ ,
+ )
+
+ expect(queryClient.getQueryData(['health', 'a@test.local'])).toBeUndefined()
+ })
+
+ it('does not clear cache while status is signedIn', () => {
+ const queryClient = new QueryClient()
+ const testData = { summary: { total_days: 100 } }
+ queryClient.setQueryData(['health', 'a@test.local'], testData)
+
+ render(
+
+
+
+ test
+
+ ,
+ )
+
+ expect(queryClient.getQueryData(['health', 'a@test.local'])).toEqual(testData)
+ })
+
+ it('does not clear cache while status is loading', () => {
+ const queryClient = new QueryClient()
+ const testData = { summary: { total_days: 100 } }
+ queryClient.setQueryData(['health', 'a@test.local'], testData)
+
+ render(
+
+
+
+ test
+
+ ,
+ )
+
+ expect(queryClient.getQueryData(['health', 'a@test.local'])).toEqual(testData)
+ })
+})
diff --git a/web/src/app/ClearCacheOnSignOut.tsx b/web/src/app/ClearCacheOnSignOut.tsx
new file mode 100644
index 0000000..b0ae08e
--- /dev/null
+++ b/web/src/app/ClearCacheOnSignOut.tsx
@@ -0,0 +1,16 @@
+import { useQueryClient } from '@tanstack/react-query'
+import { useEffect } from 'react'
+import { useAuth } from '../lib/supabase/auth'
+
+export function ClearCacheOnSignOut() {
+ const { status } = useAuth()
+ const queryClient = useQueryClient()
+
+ useEffect(() => {
+ if (status === 'signedOut') {
+ queryClient.clear()
+ }
+ }, [status, queryClient])
+
+ return null
+}
diff --git a/web/src/app/PlaceholderPage.tsx b/web/src/app/PlaceholderPage.tsx
new file mode 100644
index 0000000..cf9222d
--- /dev/null
+++ b/web/src/app/PlaceholderPage.tsx
@@ -0,0 +1,13 @@
+import type { ReactNode } from 'react'
+
+export function PlaceholderPage({ title, children }: { title: string; children: ReactNode }) {
+ return (
+
+ )
+}
diff --git a/web/src/app/ProtectedRoute.test.tsx b/web/src/app/ProtectedRoute.test.tsx
new file mode 100644
index 0000000..ec5beea
--- /dev/null
+++ b/web/src/app/ProtectedRoute.test.tsx
@@ -0,0 +1,47 @@
+import { screen } from '@testing-library/react'
+import { Route, Routes } from 'react-router-dom'
+import { describe, expect, it } from 'vitest'
+import { authValue, renderAt } from '../test/utils'
+import { ProtectedRoute } from './ProtectedRoute'
+
+function renderGuarded(status: Parameters[0]) {
+ return renderAt(
+
+ login page} />
+ }>
+ secret content} />
+
+ ,
+ { auth: authValue(status) },
+ )
+}
+
+describe('ProtectedRoute', () => {
+ it('shows a loading status while the session is being checked', () => {
+ renderGuarded({ status: 'loading' })
+ expect(screen.getByRole('status')).toHaveTextContent('Loading')
+ expect(screen.queryByText('secret content')).not.toBeInTheDocument()
+ })
+
+ it('redirects signed-out visitors to /login', () => {
+ renderGuarded({ status: 'signedOut', email: null })
+ expect(screen.getByText('login page')).toBeInTheDocument()
+ expect(screen.queryByText('secret content')).not.toBeInTheDocument()
+ })
+
+ it('renders the protected content when signed in', () => {
+ renderGuarded({ status: 'signedIn' })
+ expect(screen.getByText('secret content')).toBeInTheDocument()
+ })
+
+ it('lets visitors in when auth is not configured (local dev)', () => {
+ renderGuarded({ status: 'unconfigured', email: null })
+ expect(screen.getByText('secret content')).toBeInTheDocument()
+ })
+
+ it('shows an alert when the server cannot be reached', () => {
+ renderGuarded({ status: 'error', email: null })
+ expect(screen.getByRole('alert')).toHaveTextContent("Can't reach the Somada server")
+ expect(screen.queryByText('secret content')).not.toBeInTheDocument()
+ })
+})
diff --git a/web/src/app/ProtectedRoute.tsx b/web/src/app/ProtectedRoute.tsx
new file mode 100644
index 0000000..5894fd4
--- /dev/null
+++ b/web/src/app/ProtectedRoute.tsx
@@ -0,0 +1,12 @@
+import { Navigate, Outlet } from 'react-router-dom'
+import { useAuth } from '../lib/supabase/auth'
+
+export function ProtectedRoute() {
+ const { status } = useAuth()
+ if (status === 'loading') return Loading…
+ if (status === 'error') {
+ return Can't reach the Somada server. Try again in a moment.
+ }
+ if (status === 'signedOut') return
+ return
+}
diff --git a/web/src/app/Shell.test.tsx b/web/src/app/Shell.test.tsx
new file mode 100644
index 0000000..ffcf958
--- /dev/null
+++ b/web/src/app/Shell.test.tsx
@@ -0,0 +1,42 @@
+import { screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { Route, Routes } from 'react-router-dom'
+import { describe, expect, it, vi } from 'vitest'
+import { authValue, renderAt } from '../test/utils'
+import { Shell } from './Shell'
+
+function renderShell(auth = authValue()) {
+ return renderAt(
+
+ }>
+ page body} />
+
+ ,
+ { auth },
+ )
+}
+
+describe('Shell', () => {
+ it('has persistent navigation to Dashboard, Insights and Settings', () => {
+ renderShell()
+ const nav = screen.getByRole('navigation', { name: 'Main' })
+ expect(nav).toContainElement(screen.getByRole('link', { name: 'Dashboard' }))
+ expect(screen.getByRole('link', { name: 'Dashboard' })).toHaveAttribute('href', '/')
+ expect(screen.getByRole('link', { name: 'Insights' })).toHaveAttribute('href', '/insights')
+ expect(screen.getByRole('link', { name: 'Settings' })).toHaveAttribute('href', '/settings')
+ expect(screen.getByText('page body')).toBeInTheDocument()
+ })
+
+ it('shows the account email and signs out on request', async () => {
+ const signOut = vi.fn().mockResolvedValue(undefined)
+ renderShell(authValue({ signOut }))
+ expect(screen.getByText('a@test.local')).toBeInTheDocument()
+ await userEvent.click(screen.getByRole('button', { name: 'Sign out' }))
+ expect(signOut).toHaveBeenCalledTimes(1)
+ })
+
+ it('hides the account area when auth is not configured', () => {
+ renderShell(authValue({ status: 'unconfigured', email: null }))
+ expect(screen.queryByRole('button', { name: 'Sign out' })).not.toBeInTheDocument()
+ })
+})
diff --git a/web/src/app/Shell.tsx b/web/src/app/Shell.tsx
new file mode 100644
index 0000000..dccdf59
--- /dev/null
+++ b/web/src/app/Shell.tsx
@@ -0,0 +1,31 @@
+import { NavLink, Outlet } from 'react-router-dom'
+import { useAuth } from '../lib/supabase/auth'
+
+export function Shell() {
+ const { status, email, signOut } = useAuth()
+ return (
+
+ )
+}
diff --git a/web/src/config.test.ts b/web/src/config.test.ts
new file mode 100644
index 0000000..a7b7651
--- /dev/null
+++ b/web/src/config.test.ts
@@ -0,0 +1,14 @@
+import { describe, expect, it } from 'vitest'
+import config from '../vite.config'
+
+describe('build config', () => {
+ it('serves the app under /app/ so it can coexist with the legacy page at /', () => {
+ expect(config.base).toBe('/app/')
+ })
+
+ // Vitest forces Vite's base to '/' while testing; vite.config.ts pins BASE_URL via test.env
+ // so code under test sees the production value.
+ it('exposes the production base as import.meta.env.BASE_URL in tests', () => {
+ expect(import.meta.env.BASE_URL).toBe('/app/')
+ })
+})
diff --git a/web/src/features/auth/LoginPage.test.tsx b/web/src/features/auth/LoginPage.test.tsx
new file mode 100644
index 0000000..8ec3472
--- /dev/null
+++ b/web/src/features/auth/LoginPage.test.tsx
@@ -0,0 +1,62 @@
+import { screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { Route, Routes } from 'react-router-dom'
+import { describe, expect, it, vi } from 'vitest'
+import { authValue, renderAt } from '../../test/utils'
+import { LoginPage } from './LoginPage'
+
+function renderLogin(auth = authValue({ status: 'signedOut', email: null })) {
+ return renderAt(
+
+ } />
+ home} />
+ ,
+ { route: '/login', auth },
+ )
+}
+
+async function fillCredentials() {
+ await userEvent.type(screen.getByLabelText('Email'), 'a@test.local')
+ await userEvent.type(screen.getByLabelText('Password'), 'secret1')
+}
+
+describe('LoginPage', () => {
+ it('signs in with the typed credentials', async () => {
+ const signIn = vi.fn().mockResolvedValue({ error: null, notice: null })
+ renderLogin(authValue({ status: 'signedOut', email: null, signIn }))
+ await fillCredentials()
+ await userEvent.click(screen.getByRole('button', { name: 'Sign in' }))
+ expect(signIn).toHaveBeenCalledWith('a@test.local', 'secret1')
+ })
+
+ it('shows the error message when sign-in fails', async () => {
+ const signIn = vi.fn().mockResolvedValue({ error: 'Invalid login credentials', notice: null })
+ renderLogin(authValue({ status: 'signedOut', email: null, signIn }))
+ await fillCredentials()
+ await userEvent.click(screen.getByRole('button', { name: 'Sign in' }))
+ expect(await screen.findByRole('alert')).toHaveTextContent('Invalid login credentials')
+ })
+
+ it('creates an account and shows the confirmation notice', async () => {
+ const signUp = vi
+ .fn()
+ .mockResolvedValue({ error: null, notice: 'Check your email to confirm your account, then sign in.' })
+ renderLogin(authValue({ status: 'signedOut', email: null, signUp }))
+ await fillCredentials()
+ await userEvent.click(screen.getByRole('button', { name: 'Create account' }))
+ expect(signUp).toHaveBeenCalledWith('a@test.local', 'secret1')
+ expect(await screen.findByRole('status')).toHaveTextContent('Check your email')
+ })
+
+ it('offers Google sign-in', async () => {
+ const signInWithGoogle = vi.fn().mockResolvedValue({ error: null, notice: null })
+ renderLogin(authValue({ status: 'signedOut', email: null, signInWithGoogle }))
+ await userEvent.click(screen.getByRole('button', { name: 'Continue with Google' }))
+ expect(signInWithGoogle).toHaveBeenCalledTimes(1)
+ })
+
+ it('redirects to the dashboard once signed in', () => {
+ renderLogin(authValue({ status: 'signedIn' }))
+ expect(screen.getByText('home')).toBeInTheDocument()
+ })
+})
diff --git a/web/src/features/auth/LoginPage.tsx b/web/src/features/auth/LoginPage.tsx
new file mode 100644
index 0000000..b62297e
--- /dev/null
+++ b/web/src/features/auth/LoginPage.tsx
@@ -0,0 +1,69 @@
+import { useState } from 'react'
+import { Navigate } from 'react-router-dom'
+import { useAuth, type AuthResult } from '../../lib/supabase/auth'
+
+type Message = { kind: 'error' | 'notice'; text: string }
+
+export function LoginPage() {
+ const { status, signIn, signUp, signInWithGoogle } = useAuth()
+ const [email, setEmail] = useState('')
+ const [password, setPassword] = useState('')
+ const [message, setMessage] = useState(null)
+ const [busy, setBusy] = useState(false)
+
+ if (status === 'signedIn' || status === 'unconfigured') return
+
+ async function run(action: () => Promise) {
+ setBusy(true)
+ setMessage(null)
+ const result = await action()
+ setBusy(false)
+ if (result.error) setMessage({ kind: 'error', text: result.error })
+ else if (result.notice) setMessage({ kind: 'notice', text: result.notice })
+ }
+
+ return (
+
+ )
+}
diff --git a/web/src/features/dashboard/DashboardPage.test.tsx b/web/src/features/dashboard/DashboardPage.test.tsx
new file mode 100644
index 0000000..bb18d49
--- /dev/null
+++ b/web/src/features/dashboard/DashboardPage.test.tsx
@@ -0,0 +1,54 @@
+import { screen } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { stubFetch } from '../../test/fetch'
+import { healthFixture } from '../../test/fixtures'
+import { authValue, renderAt } from '../../test/utils'
+import { DashboardPage } from './DashboardPage'
+
+afterEach(() => {
+ vi.unstubAllGlobals()
+})
+
+function ownData() {
+ const data = healthFixture()
+ data.summary.total_days = 120
+ return data
+}
+
+describe('DashboardPage', () => {
+ it('shows a loading status, then the size of the user\'s own dataset', async () => {
+ stubFetch(200, ownData())
+ renderAt( )
+ expect(screen.getByRole('status')).toHaveTextContent('Loading your data')
+ expect(await screen.findByRole('heading', { name: 'Dashboard' })).toBeInTheDocument()
+ expect(screen.getByText('120 days of data')).toBeInTheDocument()
+ expect(screen.queryByText(/sample data/i)).not.toBeInTheDocument()
+ })
+
+ it('requests health data with the signed-in user\'s token', async () => {
+ const calls = stubFetch(200, ownData())
+ renderAt( , { auth: authValue({ getToken: async () => 'token-b' }) })
+ await screen.findByRole('heading', { name: 'Dashboard' })
+ expect(calls[0].url).toBe('/api/health')
+ expect(calls[0].headers.get('Authorization')).toBe('Bearer token-b')
+ })
+
+ it('labels the shared demo dataset as sample data and links to the classic upload', async () => {
+ stubFetch(200, { ...ownData(), is_demo: true })
+ renderAt( )
+ expect(await screen.findByText(/You're viewing sample data/)).toBeInTheDocument()
+ expect(screen.getByRole('link', { name: 'Upload your own data' })).toHaveAttribute('href', '/')
+ })
+
+ it('tells new users there is no data yet when the server has none', async () => {
+ stubFetch(200, { error: 'No health data found.' })
+ renderAt( )
+ expect(await screen.findByRole('heading', { name: 'No health data yet' })).toBeInTheDocument()
+ })
+
+ it('shows the server error message when the request fails', async () => {
+ stubFetch(401, { error: 'Sign in required.' })
+ renderAt( )
+ expect(await screen.findByRole('alert')).toHaveTextContent('Sign in required.')
+ })
+})
diff --git a/web/src/features/dashboard/DashboardPage.tsx b/web/src/features/dashboard/DashboardPage.tsx
new file mode 100644
index 0000000..be969c9
--- /dev/null
+++ b/web/src/features/dashboard/DashboardPage.tsx
@@ -0,0 +1,38 @@
+import { useQuery } from '@tanstack/react-query'
+import { getHealth } from '../../lib/api/client'
+import { useAuth } from '../../lib/supabase/auth'
+
+export function DashboardPage() {
+ const { email, getToken } = useAuth()
+ const { data, isPending, error } = useQuery({
+ queryKey: ['health', email],
+ queryFn: () => getHealth(getToken),
+ })
+
+ if (isPending) return Loading your data…
+ if (error) return {error.message}
+
+ if (data === null) {
+ return (
+
+ No health data yet
+ Uploading your Apple Health export is coming to the new app. For now, use the classic app.
+
+ Open the classic app
+
+
+ )
+ }
+
+ return (
+
+ Dashboard
+ {data.is_demo && (
+
+ You're viewing sample data. Upload your own data
+
+ )}
+ {data.summary.total_days} days of data
+
+ )
+}
diff --git a/web/src/lib/api/client.test.ts b/web/src/lib/api/client.test.ts
new file mode 100644
index 0000000..eaf5943
--- /dev/null
+++ b/web/src/lib/api/client.test.ts
@@ -0,0 +1,117 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { healthFixture } from '../../test/fixtures'
+import { stubFetch } from '../../test/fetch'
+import { ApiError, getAppConfig, getHealth, getSupabaseConfig } from './client'
+
+afterEach(() => {
+ vi.unstubAllGlobals()
+})
+
+describe('getHealth', () => {
+ it('sends the access token as a Bearer header', async () => {
+ const calls = stubFetch(200, healthFixture())
+ await getHealth(async () => 'tok-a')
+ expect(calls).toHaveLength(1)
+ expect(calls[0].url).toBe('/api/health')
+ expect(calls[0].headers.get('Authorization')).toBe('Bearer tok-a')
+ })
+
+ it('omits Authorization when there is no token (unconfigured local dev)', async () => {
+ const calls = stubFetch(200, healthFixture())
+ await getHealth(async () => null)
+ expect(calls[0].headers.has('Authorization')).toBe(false)
+ })
+
+ it('returns the data, including the server-set is_demo marker', async () => {
+ stubFetch(200, healthFixture({ is_demo: true }))
+ const data = await getHealth(async () => 'tok-a')
+ expect(data?.is_demo).toBe(true)
+ expect(data?.summary.total_days).toBe(1)
+ })
+
+ it('returns null when the server answers 200 with an error body (no data yet)', async () => {
+ stubFetch(200, { error: 'No health data found.' })
+ expect(await getHealth(async () => 'tok-a')).toBeNull()
+ })
+
+ it('throws ApiError with the server message on 401', async () => {
+ stubFetch(401, { error: 'Sign in required.' })
+ await expect(getHealth(async () => null)).rejects.toMatchObject({
+ name: 'ApiError',
+ status: 401,
+ message: 'Sign in required.',
+ })
+ })
+
+ it('falls back to a generic message when the error body has no message', async () => {
+ stubFetch(500, null)
+ await expect(getHealth(async () => 'tok-a')).rejects.toBeInstanceOf(ApiError)
+ await expect(getHealth(async () => 'tok-a')).rejects.toMatchObject({
+ message: 'Request failed (500)',
+ })
+ })
+
+ it('treats an empty 200 body as an error instead of crashing', async () => {
+ stubFetch(200, null)
+ await expect(getHealth(async () => 'tok-a')).rejects.toMatchObject({ status: 200 })
+ })
+
+ it('rejects a non-object JSON body', async () => {
+ stubFetch(200, 'ok')
+ await expect(getHealth(async () => 'tok-a')).rejects.toMatchObject({
+ name: 'ApiError',
+ status: 200,
+ message: 'Unexpected response',
+ })
+ })
+
+ it('passes null metrics and summary stats through unchanged', async () => {
+ const payload = {
+ daily: {
+ '2026-09-29': {
+ date: '2026-09-29',
+ steps: null,
+ heart_rate_avg: null,
+ heart_rate_min: null,
+ heart_rate_max: null,
+ resting_hr: null,
+ hrv: null,
+ sleep_hours: null,
+ active_calories: null,
+ exercise_minutes: null,
+ spo2: null,
+ },
+ },
+ summary: {
+ avg_steps: null,
+ avg_sleep_hours: null,
+ avg_resting_hr: null,
+ avg_hrv: null,
+ best_sleep: null,
+ worst_sleep: null,
+ best_steps_day: null,
+ total_days: 1,
+ },
+ events: [],
+ generated_at: '2026-09-30T00:00:00',
+ }
+ stubFetch(200, payload)
+ expect(await getHealth(async () => 'tok-a')).toEqual(payload)
+ })
+})
+
+describe('public config endpoints', () => {
+ it('getSupabaseConfig calls /api/supabase_config without credentials', async () => {
+ const calls = stubFetch(200, { url: 'https://x.supabase.co', anonKey: 'k' })
+ const cfg = await getSupabaseConfig()
+ expect(cfg).toEqual({ url: 'https://x.supabase.co', anonKey: 'k' })
+ expect(calls[0].url).toBe('/api/supabase_config')
+ expect(calls[0].headers.has('Authorization')).toBe(false)
+ })
+
+ it('getAppConfig calls /api/config', async () => {
+ const calls = stubFetch(200, { chat: { byokSupported: true } })
+ await getAppConfig()
+ expect(calls[0].url).toBe('/api/config')
+ })
+})
diff --git a/web/src/lib/api/client.ts b/web/src/lib/api/client.ts
new file mode 100644
index 0000000..952ff9c
--- /dev/null
+++ b/web/src/lib/api/client.ts
@@ -0,0 +1,65 @@
+import type { HealthData } from '../health/types'
+
+export type TokenProvider = () => Promise
+
+export class ApiError extends Error {
+ readonly status: number
+
+ constructor(status: number, message: string) {
+ super(message)
+ this.name = 'ApiError'
+ this.status = status
+ }
+}
+
+export interface SupabaseConfig {
+ url: string
+ anonKey: string
+}
+
+export interface ChatConfig {
+ serverVertex: boolean
+ serverModel: string
+ serverClaude: boolean
+ claudeModel: string
+ byokSupported: boolean
+}
+
+export interface AppConfig {
+ chat: ChatConfig
+}
+
+function errorMessage(body: unknown, status: number): string {
+ if (typeof body === 'object' && body !== null && 'error' in body) {
+ const { error } = body as { error: unknown }
+ if (typeof error === 'string') return error
+ }
+ return `Request failed (${status})`
+}
+
+async function request(path: string, getToken: TokenProvider | null): Promise {
+ const headers = new Headers()
+ if (getToken) {
+ const token = await getToken()
+ if (token) headers.set('Authorization', `Bearer ${token}`)
+ }
+ const res = await fetch(path, { headers })
+ const body: unknown = await res.json().catch(() => null)
+ if (!res.ok) throw new ApiError(res.status, errorMessage(body, res.status))
+ if (typeof body !== 'object' || body === null) throw new ApiError(res.status, 'Unexpected response')
+ return body as T
+}
+
+export function getSupabaseConfig(): Promise {
+ return request('/api/supabase_config', null)
+}
+
+export function getAppConfig(): Promise {
+ return request('/api/config', null)
+}
+
+/** The caller's health data, or null when the server has none for them yet. */
+export async function getHealth(getToken: TokenProvider): Promise {
+ const body = await request('/api/health', getToken)
+ return 'error' in body ? null : body
+}
diff --git a/web/src/lib/health/types.ts b/web/src/lib/health/types.ts
new file mode 100644
index 0000000..96b02a6
--- /dev/null
+++ b/web/src/lib/health/types.ts
@@ -0,0 +1,43 @@
+// Shape documented in CLAUDE.md ("Health Data Shape"). `daily` is a dictionary keyed by date, not an array.
+// Metrics are null on days with no data (see parse_health.py and the in-browser parser in index.html).
+export interface DailyEntry {
+ date: string
+ steps: number | null
+ heart_rate_avg: number | null
+ heart_rate_min: number | null
+ heart_rate_max: number | null
+ resting_hr: number | null
+ hrv: number | null
+ sleep_hours: number | null
+ active_calories: number | null
+ exercise_minutes: number | null
+ spo2: number | null
+}
+
+export interface HealthSummary {
+ avg_steps: number | null
+ avg_sleep_hours: number | null
+ avg_resting_hr: number | null
+ avg_hrv: number | null
+ best_sleep: number | null
+ worst_sleep: number | null
+ best_steps_day: string | null
+ total_days: number
+}
+
+export interface LifeEvent {
+ label: string
+ start: string
+ end: string
+ color: string
+ icon: string
+}
+
+export interface HealthData {
+ daily: Record
+ summary: HealthSummary
+ events: LifeEvent[]
+ generated_at: string
+ /** Set by the server when this is the shared synthetic demo dataset rather than the user's own data. */
+ is_demo?: boolean
+}
diff --git a/web/src/lib/queryClient.test.ts b/web/src/lib/queryClient.test.ts
new file mode 100644
index 0000000..e5c7901
--- /dev/null
+++ b/web/src/lib/queryClient.test.ts
@@ -0,0 +1,55 @@
+import { describe, expect, it } from 'vitest'
+import { ApiError } from './api/client'
+import { createQueryClient, shouldRetry } from './queryClient'
+
+describe('shouldRetry', () => {
+ it('returns false for ApiError with 401 status at failureCount 0', () => {
+ const error = new ApiError(401, 'Unauthorized')
+ expect(shouldRetry(0, error)).toBe(false)
+ })
+
+ it('returns false for ApiError with 4xx status', () => {
+ const error400 = new ApiError(400, 'Bad Request')
+ const error403 = new ApiError(403, 'Forbidden')
+ const error404 = new ApiError(404, 'Not Found')
+ expect(shouldRetry(0, error400)).toBe(false)
+ expect(shouldRetry(0, error403)).toBe(false)
+ expect(shouldRetry(0, error404)).toBe(false)
+ })
+
+ it('returns true for ApiError with 5xx status at failureCount 0 and 1', () => {
+ const error = new ApiError(500, 'Server Error')
+ expect(shouldRetry(0, error)).toBe(true)
+ expect(shouldRetry(1, error)).toBe(true)
+ })
+
+ it('returns false for ApiError with 5xx status at failureCount 2', () => {
+ const error = new ApiError(500, 'Server Error')
+ expect(shouldRetry(2, error)).toBe(false)
+ })
+
+ it('returns true for non-ApiError at failureCount 0 and 1', () => {
+ const error = new TypeError('Network error')
+ expect(shouldRetry(0, error)).toBe(true)
+ expect(shouldRetry(1, error)).toBe(true)
+ })
+
+ it('returns false for non-ApiError at failureCount 2', () => {
+ const error = new TypeError('Network error')
+ expect(shouldRetry(2, error)).toBe(false)
+ })
+})
+
+describe('createQueryClient', () => {
+ it('creates a QueryClient with shouldRetry as retry function', () => {
+ const queryClient = createQueryClient()
+ const retryFn = queryClient.getDefaultOptions().queries?.retry
+ expect(retryFn).toBe(shouldRetry)
+ })
+
+ it('sets refetchOnWindowFocus to false', () => {
+ const queryClient = createQueryClient()
+ const refetchOnWindowFocus = queryClient.getDefaultOptions().queries?.refetchOnWindowFocus
+ expect(refetchOnWindowFocus).toBe(false)
+ })
+})
diff --git a/web/src/lib/queryClient.ts b/web/src/lib/queryClient.ts
new file mode 100644
index 0000000..a47eab1
--- /dev/null
+++ b/web/src/lib/queryClient.ts
@@ -0,0 +1,23 @@
+import { QueryClient } from '@tanstack/react-query'
+import { ApiError } from './api/client'
+
+export function shouldRetry(failureCount: number, error: unknown): boolean {
+ if (error instanceof ApiError) {
+ const status = error.status
+ if (status >= 400 && status < 500) {
+ return false
+ }
+ }
+ return failureCount < 2
+}
+
+export function createQueryClient(): QueryClient {
+ return new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: shouldRetry,
+ refetchOnWindowFocus: false,
+ },
+ },
+ })
+}
diff --git a/web/src/lib/supabase/auth.test.tsx b/web/src/lib/supabase/auth.test.tsx
new file mode 100644
index 0000000..1508835
--- /dev/null
+++ b/web/src/lib/supabase/auth.test.tsx
@@ -0,0 +1,176 @@
+import type { Session, SupabaseClient } from '@supabase/supabase-js'
+import { act, render, screen, waitFor } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import { AuthProvider, useAuth, type AuthContextValue } from './auth'
+
+const session = { access_token: 'tok-a', user: { email: 'a@test.local' } } as unknown as Session
+
+function fakeClient(initial: Session | null) {
+ let listener: ((event: string, s: Session | null) => void) | undefined
+ const unsubscribe = vi.fn()
+ const auth = {
+ getSession: vi.fn().mockResolvedValue({ data: { session: initial } }),
+ onAuthStateChange: vi.fn((cb: (event: string, s: Session | null) => void) => {
+ listener = cb
+ return { data: { subscription: { unsubscribe } } }
+ }),
+ signInWithPassword: vi.fn().mockResolvedValue({ error: null }),
+ signUp: vi.fn().mockResolvedValue({ data: { session: null }, error: null }),
+ signInWithOAuth: vi.fn().mockResolvedValue({ error: null }),
+ signOut: vi.fn().mockResolvedValue({ error: null }),
+ }
+ return {
+ client: { auth } as unknown as SupabaseClient,
+ auth,
+ unsubscribe,
+ emit: (s: Session | null) => listener?.(s ? 'SIGNED_IN' : 'SIGNED_OUT', s),
+ }
+}
+
+let ctx!: AuthContextValue
+function Capture() {
+ ctx = useAuth()
+ return (
+ <>
+ {ctx.status}
+ {ctx.email ?? ''}
+ >
+ )
+}
+
+function renderProvider(loadClient: () => Promise) {
+ return render(
+
+
+ ,
+ )
+}
+
+const expectStatus = (value: string) =>
+ waitFor(() => expect(screen.getByTestId('status')).toHaveTextContent(value))
+
+describe('AuthProvider', () => {
+ it('starts loading, then reports signedIn with the email when a session exists', async () => {
+ const { client } = fakeClient(session)
+ renderProvider(async () => client)
+ expect(screen.getByTestId('status')).toHaveTextContent('loading')
+ await expectStatus('signedIn')
+ expect(screen.getByTestId('email')).toHaveTextContent('a@test.local')
+ })
+
+ it('reports signedOut when there is no session', async () => {
+ const { client } = fakeClient(null)
+ renderProvider(async () => client)
+ await expectStatus('signedOut')
+ })
+
+ it('reports unconfigured when Supabase is not configured (local dev)', async () => {
+ renderProvider(async () => null)
+ await expectStatus('unconfigured')
+ expect(await ctx.getToken()).toBeNull()
+ })
+
+ it('reports error when the Supabase config cannot be loaded', async () => {
+ renderProvider(async () => {
+ throw new Error('network down')
+ })
+ await expectStatus('error')
+ })
+
+ it('follows auth state changes', async () => {
+ const { client, emit } = fakeClient(null)
+ renderProvider(async () => client)
+ await expectStatus('signedOut')
+ act(() => emit(session))
+ await expectStatus('signedIn')
+ act(() => emit(null))
+ await expectStatus('signedOut')
+ })
+
+ it('getToken returns the current access token', async () => {
+ const { client } = fakeClient(session)
+ renderProvider(async () => client)
+ await expectStatus('signedIn')
+ expect(await ctx.getToken()).toBe('tok-a')
+ })
+
+ it('signIn forwards credentials and returns the Supabase error message', async () => {
+ const { client, auth } = fakeClient(null)
+ auth.signInWithPassword.mockResolvedValueOnce({ error: { message: 'Invalid login credentials' } })
+ renderProvider(async () => client)
+ await expectStatus('signedOut')
+ let result!: Awaited>
+ await act(async () => {
+ result = await ctx.signIn('a@test.local', 'pw')
+ })
+ expect(auth.signInWithPassword).toHaveBeenCalledWith({ email: 'a@test.local', password: 'pw' })
+ expect(result).toEqual({ error: 'Invalid login credentials', notice: null })
+ })
+
+ it('signUp without a session asks the user to confirm their email', async () => {
+ const { client } = fakeClient(null)
+ renderProvider(async () => client)
+ await expectStatus('signedOut')
+ let result!: Awaited>
+ await act(async () => {
+ result = await ctx.signUp('new@test.local', 'secret1')
+ })
+ expect(result.error).toBeNull()
+ expect(result.notice).toMatch(/confirm/i)
+ })
+
+ it('signUp surfaces Supabase errors', async () => {
+ const { client, auth } = fakeClient(null)
+ auth.signUp.mockResolvedValueOnce({ data: { session: null }, error: { message: 'User already registered' } })
+ renderProvider(async () => client)
+ await expectStatus('signedOut')
+ let result!: Awaited>
+ await act(async () => {
+ result = await ctx.signUp('a@test.local', 'secret1')
+ })
+ expect(result).toEqual({ error: 'User already registered', notice: null })
+ })
+
+ it('signInWithGoogle redirects back to the SPA base path', async () => {
+ const { client, auth } = fakeClient(null)
+ renderProvider(async () => client)
+ await expectStatus('signedOut')
+ await act(async () => {
+ await ctx.signInWithGoogle()
+ })
+ expect(auth.signInWithOAuth).toHaveBeenCalledWith({
+ provider: 'google',
+ options: { redirectTo: `${window.location.origin}/app/` },
+ })
+ })
+
+ it('signOut signs out of Supabase', async () => {
+ const { client, auth } = fakeClient(session)
+ renderProvider(async () => client)
+ await expectStatus('signedIn')
+ await act(async () => {
+ await ctx.signOut()
+ })
+ expect(auth.signOut).toHaveBeenCalledTimes(1)
+ })
+
+ it('signOut falls back to local scope when global sign-out returns an error', async () => {
+ const { client, auth } = fakeClient(session)
+ auth.signOut.mockResolvedValueOnce({ error: { message: 'x' } })
+ renderProvider(async () => client)
+ await expectStatus('signedIn')
+ await act(async () => {
+ await ctx.signOut()
+ })
+ expect(auth.signOut).toHaveBeenCalledTimes(2)
+ expect(auth.signOut).toHaveBeenLastCalledWith({ scope: 'local' })
+ })
+
+ it('unsubscribes from auth changes on unmount', async () => {
+ const { client, unsubscribe } = fakeClient(null)
+ const { unmount } = renderProvider(async () => client)
+ await expectStatus('signedOut')
+ unmount()
+ expect(unsubscribe).toHaveBeenCalled()
+ })
+})
diff --git a/web/src/lib/supabase/auth.tsx b/web/src/lib/supabase/auth.tsx
new file mode 100644
index 0000000..8a84960
--- /dev/null
+++ b/web/src/lib/supabase/auth.tsx
@@ -0,0 +1,117 @@
+import type { Session, SupabaseClient } from '@supabase/supabase-js'
+import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from 'react'
+import { getSupabase } from './client'
+
+export type AuthStatus = 'loading' | 'signedOut' | 'signedIn' | 'unconfigured' | 'error'
+
+export interface AuthResult {
+ error: string | null
+ notice: string | null
+}
+
+export interface AuthContextValue {
+ status: AuthStatus
+ email: string | null
+ getToken: () => Promise
+ signIn: (email: string, password: string) => Promise
+ signUp: (email: string, password: string) => Promise
+ signInWithGoogle: () => Promise
+ signOut: () => Promise
+}
+
+export const AuthContext = createContext(null)
+
+const NOT_CONFIGURED: AuthResult = { error: 'Sign-in is not configured.', notice: null }
+
+export function AuthProvider({
+ children,
+ loadClient = getSupabase,
+}: {
+ children: ReactNode
+ loadClient?: () => Promise
+}) {
+ const [client, setClient] = useState(null)
+ const [session, setSession] = useState(null)
+ const [status, setStatus] = useState('loading')
+
+ useEffect(() => {
+ let cancelled = false
+ let unsubscribe: (() => void) | undefined
+
+ loadClient()
+ .then(async (c) => {
+ if (cancelled) return
+ if (!c) {
+ setStatus('unconfigured')
+ return
+ }
+ setClient(c)
+ const { data } = await c.auth.getSession()
+ if (cancelled) return
+ setSession(data.session)
+ setStatus(data.session ? 'signedIn' : 'signedOut')
+ const { data: sub } = c.auth.onAuthStateChange((_event, next) => {
+ setSession(next)
+ setStatus(next ? 'signedIn' : 'signedOut')
+ })
+ unsubscribe = () => sub.subscription.unsubscribe()
+ })
+ .catch(() => {
+ if (!cancelled) setStatus('error')
+ })
+
+ return () => {
+ cancelled = true
+ unsubscribe?.()
+ }
+ }, [loadClient])
+
+ const value = useMemo(
+ () => ({
+ status,
+ email: session?.user.email ?? null,
+ getToken: async () => {
+ if (!client) return null
+ const { data } = await client.auth.getSession()
+ return data.session?.access_token ?? null
+ },
+ signIn: async (email, password) => {
+ if (!client) return NOT_CONFIGURED
+ const { error } = await client.auth.signInWithPassword({ email, password })
+ return { error: error?.message ?? null, notice: null }
+ },
+ signUp: async (email, password) => {
+ if (!client) return NOT_CONFIGURED
+ const { data, error } = await client.auth.signUp({ email, password })
+ if (error) return { error: error.message, notice: null }
+ return {
+ error: null,
+ notice: data.session ? null : 'Check your email to confirm your account, then sign in.',
+ }
+ },
+ signInWithGoogle: async () => {
+ if (!client) return NOT_CONFIGURED
+ const { error } = await client.auth.signInWithOAuth({
+ provider: 'google',
+ options: { redirectTo: `${window.location.origin}${import.meta.env.BASE_URL}` },
+ })
+ return { error: error?.message ?? null, notice: null }
+ },
+ signOut: async () => {
+ if (!client) return
+ const { error } = (await client.auth.signOut()) ?? {}
+ // Global sign-out can fail (network/5xx) without clearing the local session.
+ if (error) await client.auth.signOut({ scope: 'local' })
+ },
+ }),
+ [client, session, status],
+ )
+
+ return {children}
+}
+
+export function useAuth(): AuthContextValue {
+ const value = useContext(AuthContext)
+ if (!value) throw new Error('useAuth must be used inside ')
+ return value
+}
diff --git a/web/src/lib/supabase/client.test.ts b/web/src/lib/supabase/client.test.ts
new file mode 100644
index 0000000..934929e
--- /dev/null
+++ b/web/src/lib/supabase/client.test.ts
@@ -0,0 +1,59 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const getSupabaseConfig = vi.fn()
+const createClient = vi.fn()
+
+vi.mock('../api/client', () => ({ getSupabaseConfig: () => getSupabaseConfig() }))
+vi.mock('@supabase/supabase-js', () => ({
+ createClient: (url: string, key: string) => createClient(url, key),
+}))
+
+beforeEach(() => {
+ vi.resetModules()
+ getSupabaseConfig.mockReset()
+ createClient.mockReset()
+})
+
+async function load() {
+ return (await import('./client')).getSupabase
+}
+
+describe('getSupabase', () => {
+ it('resolves null without creating a client when url is empty', async () => {
+ getSupabaseConfig.mockResolvedValue({ url: '', anonKey: 'k' })
+ const getSupabase = await load()
+ expect(await getSupabase()).toBeNull()
+ expect(createClient).not.toHaveBeenCalled()
+ })
+
+ it('resolves null when anonKey is empty', async () => {
+ getSupabaseConfig.mockResolvedValue({ url: 'https://x.supabase.co', anonKey: '' })
+ const getSupabase = await load()
+ expect(await getSupabase()).toBeNull()
+ expect(createClient).not.toHaveBeenCalled()
+ })
+
+ it('creates the client once and reuses it', async () => {
+ const sentinel = { sentinel: true }
+ createClient.mockReturnValue(sentinel)
+ getSupabaseConfig.mockResolvedValue({ url: 'https://x.supabase.co', anonKey: 'k' })
+ const getSupabase = await load()
+ expect(await getSupabase()).toBe(sentinel)
+ expect(await getSupabase()).toBe(sentinel)
+ expect(createClient).toHaveBeenCalledTimes(1)
+ expect(createClient).toHaveBeenCalledWith('https://x.supabase.co', 'k')
+ expect(getSupabaseConfig).toHaveBeenCalledTimes(1)
+ })
+
+ it('does not cache a failed config fetch and retries on the next call', async () => {
+ const sentinel = { sentinel: true }
+ createClient.mockReturnValue(sentinel)
+ getSupabaseConfig
+ .mockRejectedValueOnce(new Error('boom'))
+ .mockResolvedValueOnce({ url: 'https://x.supabase.co', anonKey: 'k' })
+ const getSupabase = await load()
+ await expect(getSupabase()).rejects.toThrow('boom')
+ expect(await getSupabase()).toBe(sentinel)
+ expect(getSupabaseConfig).toHaveBeenCalledTimes(2)
+ })
+})
diff --git a/web/src/lib/supabase/client.ts b/web/src/lib/supabase/client.ts
new file mode 100644
index 0000000..0ae4a29
--- /dev/null
+++ b/web/src/lib/supabase/client.ts
@@ -0,0 +1,19 @@
+import { createClient, type SupabaseClient } from '@supabase/supabase-js'
+import { getSupabaseConfig } from '../api/client'
+
+let clientPromise: Promise | null = null
+
+/**
+ * The Supabase client, built once from the runtime config served by the API.
+ * Resolves to null when Supabase is not configured (local dev). A failed config
+ * fetch is not cached, so the next call retries.
+ */
+export function getSupabase(): Promise {
+ clientPromise ??= getSupabaseConfig()
+ .then((cfg) => (cfg.url && cfg.anonKey ? createClient(cfg.url, cfg.anonKey) : null))
+ .catch((err: unknown) => {
+ clientPromise = null
+ throw err
+ })
+ return clientPromise
+}
diff --git a/web/src/main.tsx b/web/src/main.tsx
new file mode 100644
index 0000000..eab8dbf
--- /dev/null
+++ b/web/src/main.tsx
@@ -0,0 +1,24 @@
+import { QueryClientProvider } from '@tanstack/react-query'
+import { StrictMode } from 'react'
+import { createRoot } from 'react-dom/client'
+import { BrowserRouter } from 'react-router-dom'
+import { ClearCacheOnSignOut } from './app/ClearCacheOnSignOut'
+import { AppRoutes } from './app/AppRoutes'
+import { AuthProvider } from './lib/supabase/auth'
+import { createQueryClient } from './lib/queryClient'
+import './styles.css'
+
+const queryClient = createQueryClient()
+
+createRoot(document.getElementById('root')!).render(
+
+
+
+
+
+
+
+
+
+ ,
+)
diff --git a/web/src/styles.css b/web/src/styles.css
new file mode 100644
index 0000000..778f190
--- /dev/null
+++ b/web/src/styles.css
@@ -0,0 +1,116 @@
+:root {
+ --bg: #ffffff;
+ --fg: #16181d;
+ --muted: #5b6270;
+ --line: #e2e5ea;
+ --accent: #0b6bcb;
+ --warn-bg: #fff6df;
+ --warn-fg: #7a5200;
+ color-scheme: light dark;
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --bg: #101216;
+ --fg: #eceef2;
+ --muted: #9aa3b2;
+ --line: #272b33;
+ --accent: #6cb0ff;
+ --warn-bg: #3a2f10;
+ --warn-fg: #ffd98a;
+ }
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ background: var(--bg);
+ color: var(--fg);
+ font: 16px/1.5 system-ui, -apple-system, 'Segoe UI', sans-serif;
+}
+
+a {
+ color: var(--accent);
+}
+
+.shell-header {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px 24px;
+ padding: 12px 16px;
+ border-bottom: 1px solid var(--line);
+}
+
+.brand {
+ font-weight: 700;
+}
+
+.shell-header nav {
+ display: flex;
+ gap: 16px;
+ flex: 1;
+}
+
+.shell-header nav a {
+ color: var(--muted);
+ text-decoration: none;
+}
+
+.shell-header nav a.active {
+ color: var(--fg);
+ font-weight: 600;
+}
+
+.account {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ color: var(--muted);
+}
+
+.shell-main {
+ max-width: 960px;
+ margin: 0 auto;
+ padding: 24px 16px;
+}
+
+.login {
+ display: grid;
+ gap: 12px;
+ max-width: 360px;
+ margin: 10vh auto 0;
+ padding: 0 16px;
+}
+
+.login label {
+ display: grid;
+ gap: 4px;
+}
+
+.login input {
+ padding: 8px;
+ font: inherit;
+}
+
+.actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+button {
+ padding: 8px 14px;
+ font: inherit;
+ cursor: pointer;
+}
+
+.demo-banner {
+ padding: 10px 12px;
+ border-radius: 6px;
+ background: var(--warn-bg);
+ color: var(--warn-fg);
+}
diff --git a/web/src/test/fetch.ts b/web/src/test/fetch.ts
new file mode 100644
index 0000000..bb051b3
--- /dev/null
+++ b/web/src/test/fetch.ts
@@ -0,0 +1,19 @@
+import { vi } from 'vitest'
+
+export interface RecordedCall {
+ url: string
+ headers: Headers
+}
+
+/** Replace global fetch with a stub that records calls and answers every request with `body` as JSON. */
+export function stubFetch(status: number, body: unknown): RecordedCall[] {
+ const calls: RecordedCall[] = []
+ vi.stubGlobal('fetch', async (input: RequestInfo | URL, init?: RequestInit) => {
+ calls.push({ url: String(input), headers: new Headers(init?.headers) })
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { 'Content-Type': 'application/json' },
+ })
+ })
+ return calls
+}
diff --git a/web/src/test/fixtures.ts b/web/src/test/fixtures.ts
new file mode 100644
index 0000000..fba5eec
--- /dev/null
+++ b/web/src/test/fixtures.ts
@@ -0,0 +1,34 @@
+import type { HealthData } from '../lib/health/types'
+
+export function healthFixture(overrides: Partial = {}): HealthData {
+ return {
+ daily: {
+ '2026-09-30': {
+ date: '2026-09-30',
+ steps: 8000,
+ heart_rate_avg: 72,
+ heart_rate_min: 52,
+ heart_rate_max: 140,
+ resting_hr: 61,
+ hrv: 48,
+ sleep_hours: 7.1,
+ active_calories: 420,
+ exercise_minutes: 35,
+ spo2: 97.5,
+ },
+ },
+ summary: {
+ avg_steps: 8000,
+ avg_sleep_hours: 7.1,
+ avg_resting_hr: 61,
+ avg_hrv: 48,
+ best_sleep: 7.1,
+ worst_sleep: 7.1,
+ best_steps_day: '2026-09-30',
+ total_days: 1,
+ },
+ events: [],
+ generated_at: '2026-09-30T00:00:00',
+ ...overrides,
+ }
+}
diff --git a/web/src/test/setup.ts b/web/src/test/setup.ts
new file mode 100644
index 0000000..a9d0dd3
--- /dev/null
+++ b/web/src/test/setup.ts
@@ -0,0 +1 @@
+import '@testing-library/jest-dom/vitest'
diff --git a/web/src/test/utils.tsx b/web/src/test/utils.tsx
new file mode 100644
index 0000000..aed0063
--- /dev/null
+++ b/web/src/test/utils.tsx
@@ -0,0 +1,33 @@
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { render } from '@testing-library/react'
+import type { ReactElement } from 'react'
+import { MemoryRouter } from 'react-router-dom'
+import { vi } from 'vitest'
+import { AuthContext, type AuthContextValue } from '../lib/supabase/auth'
+
+export function authValue(overrides: Partial = {}): AuthContextValue {
+ return {
+ status: 'signedIn',
+ email: 'a@test.local',
+ getToken: async () => 'token-a',
+ signIn: vi.fn().mockResolvedValue({ error: null, notice: null }),
+ signUp: vi.fn().mockResolvedValue({ error: null, notice: null }),
+ signInWithGoogle: vi.fn().mockResolvedValue({ error: null, notice: null }),
+ signOut: vi.fn().mockResolvedValue(undefined),
+ ...overrides,
+ }
+}
+
+export function renderAt(
+ ui: ReactElement,
+ { route = '/', auth = authValue() }: { route?: string; auth?: AuthContextValue } = {},
+) {
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
+ return render(
+
+
+ {ui}
+
+ ,
+ )
+}
diff --git a/web/tsconfig.json b/web/tsconfig.json
new file mode 100644
index 0000000..b8fc534
--- /dev/null
+++ b/web/tsconfig.json
@@ -0,0 +1,18 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "jsx": "react-jsx",
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "isolatedModules": true,
+ "skipLibCheck": true,
+ "noEmit": true,
+ "types": ["vite/client", "vitest/globals", "@testing-library/jest-dom"]
+ },
+ "include": ["src", "vite.config.ts"]
+}
diff --git a/web/vite.config.ts b/web/vite.config.ts
new file mode 100644
index 0000000..769f830
--- /dev/null
+++ b/web/vite.config.ts
@@ -0,0 +1,19 @@
+import react from '@vitejs/plugin-react'
+import { defineConfig } from 'vitest/config'
+
+// The SPA is served under /app/ so it can sit next to the legacy page at / until cutover.
+export default defineConfig({
+ base: '/app/',
+ plugins: [react()],
+ server: {
+ // `vercel dev` serves the Python API on :3000
+ proxy: { '/api': 'http://localhost:3000' },
+ },
+ test: {
+ environment: 'jsdom',
+ globals: true,
+ setupFiles: './src/test/setup.ts',
+ // Vitest forces Vite's base to '/' while testing, so pin BASE_URL to the real build value.
+ env: { BASE_URL: '/app/' },
+ },
+})